Files
OpenMotion/test/scooters/apollo_protocol_test.dart
T
2026-09-21 18:03:21 -04:00

572 lines
22 KiB
Dart

import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:openscooter/scooters/apollo_protocol.dart';
import 'package:openscooter/scooters/scooter.dart';
Uint8List hex(String s) => Uint8List.fromList(
s.trim().split(RegExp(r'\s+')).map((h) => int.parse(h, radix: 16)).toList(),
);
/// Synthetic monitor frame from the brief (section 35). CRC valid.
final monitorFrame =
hex('AA 00 00 00 02 4B 00 FA 00 C8 01 E0 01 40 23 28 00 7B 00 30 39 8A 24 E9 1F');
/// Synthetic base frame from the brief (section 46). CRC valid.
final baseFrame =
hex('AB 01 00 19 06 0C 14 1E 80 00 1F 00 27 10 13 88 00 64 12 AB 02 05 11 F0 C0');
/// Re-signs a frame after editing its payload so parser tests only fail on
/// the field under test, never on CRC.
Uint8List resign(Uint8List frame) {
final out = Uint8List.fromList(frame);
final crc = apolloCrc16(out.sublist(0, out.length - 2));
out[out.length - 2] = crc & 0xFF;
out[out.length - 1] = crc >> 8;
return out;
}
Uint8List patched(Uint8List frame, Map<int, int> bytes) {
final out = Uint8List.fromList(frame);
bytes.forEach((i, v) => out[i] = v);
return resign(out);
}
/// LIVE captures from an Apollo Go, 2026-09-21, idle, full battery,
/// headlight off. Regression fixtures per brief section 81.
final realCmd0Idle =
hex('AA 00 19 01 02 64 00 00 00 00 01 9F 00 00 16 19 00 00 00 0C C5 0E 22 B3 13');
final realCmd0HeadlightCruise =
hex('AA 00 19 01 02 64 00 00 00 00 01 9D 00 00 16 19 00 00 00 0C C5 8E 26 2A D7');
final realCmd0RightSignal =
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 2E 2E 57 15');
final realCmd0LeftSignal =
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 4E 36 7F 1F');
final realCmd1Base =
hex('AA 01 19 03 0A 0F 1E D8 80 00 1F 00 00 00 00 00 00 00 00 00 00 00 00 68 61');
void main() {
group('real Apollo Go captures', () {
test('all fixtures are CRC valid', () {
for (final f in [realCmd0Idle, realCmd0HeadlightCruise, realCmd0RightSignal,
realCmd0LeftSignal, realCmd1Base]) {
expect(apolloValidateFrame(f), isTrue, reason: apolloHex(f));
}
});
test('real cmd1 base frame', () {
final b = parseApolloBaseFrame(realCmd1Base);
expect(b.limitCruise, 3);
expect(b.limitMode1, 10);
expect(b.limitMode2, 15);
expect(b.limitMode3, 30);
expect(b.batteryTemperature, -40);
expect(b.totalBatteryCapacity, 0);
expect(b.remainingBatteryCapacity, 0);
expect(b.batteryCycles, 0);
expect(b.displayId, isNull);
expect(b.displayVersion, 'V0.0.0');
expect(b.faultEnable, isTrue);
expect([b.ctrlSn, b.ctrlMp3, b.ctrlRgb, b.ctrlBms, b.internalSpeedScalingFlag],
everyElement(isTrue));
});
test('real cmd0 idle frame with the Go scaling flag', () {
final m = parseApolloMonitorFrame(realCmd0Idle, internalSpeedScalingFlag: true);
expect(m.gear, 2);
expect(m.batteryLevel, 100);
expect(m.rawSpeed, 0);
expect(m.speed, 0.0);
expect(m.voltage, 41.5);
expect(m.current, 0.0);
expect(m.power, 0.0);
expect(m.motorTemperature, 22);
expect(m.controllerTemperature, 25);
expect(m.tripDistance, 0.0);
expect(m.odometer, 326.9);
expect(m.atmosphereLight, isTrue);
expect(m.unlocked, isTrue);
expect(m.headlight, isFalse);
expect(m.leftTurnSignal, isFalse);
expect(m.rightTurnSignal, isFalse);
expect(m.cruiseControl, isFalse);
expect(m.imperial, isTrue);
expect(m.bootMode, isFalse);
});
test('real cmd0 variants: headlight+cruise, right signal, left signal', () {
final h = parseApolloMonitorFrame(realCmd0HeadlightCruise, internalSpeedScalingFlag: true);
expect(h.headlight, isTrue);
expect(h.cruiseControl, isTrue);
expect(h.voltage, 41.3);
final r = parseApolloMonitorFrame(realCmd0RightSignal, internalSpeedScalingFlag: true);
expect(r.rightTurnSignal, isTrue);
expect(r.leftTurnSignal, isFalse);
final l = parseApolloMonitorFrame(realCmd0LeftSignal, internalSpeedScalingFlag: true);
expect(l.leftTurnSignal, isTrue);
expect(l.rightTurnSignal, isFalse);
});
test('real 20 + 5 byte notification split reassembles', () {
final buf = ApolloFrameBuffer();
expect(buf.add(realCmd0Idle.sublist(0, 20)), isEmpty);
expect(buf.add(realCmd0Idle.sublist(20)), [realCmd0Idle]);
expect(buf.add(realCmd1Base.sublist(0, 20)), isEmpty);
expect(buf.add(realCmd1Base.sublist(20)), [realCmd1Base]);
});
});
group('CRC-16', () {
test('vector 1 from brief', () {
expect(apolloCrc16(hex('AB 00 0A 00 00 00 00 00')), 0x6A0B);
});
test('vector 2 from brief', () {
expect(apolloCrc16(hex('AB 00 0A 80 19 06 0C 14')), 0xE6E8);
});
test('wire order is little-endian and covers header through length-3', () {
final f = hex('AB 00 0A 00 00 00 00 00 0B 6A');
expect(apolloValidateFrame(f), isTrue);
expect(apolloValidateFrame(hex('AB 00 0A 00 00 00 00 00 6A 0B')), isFalse);
});
test('synthetic frames validate', () {
expect(apolloValidateFrame(monitorFrame), isTrue);
expect(apolloValidateFrame(baseFrame), isTrue);
});
test('corrupting one byte fails validation', () {
final bad = Uint8List.fromList(monitorFrame)..[5] ^= 0x01;
expect(apolloValidateFrame(bad), isFalse);
// Including unknown metadata bytes 2 and 3: they are CRC-covered.
final bad2 = Uint8List.fromList(monitorFrame)..[2] = 0x01;
expect(apolloValidateFrame(bad2), isFalse);
});
test('keepalive constant is not frame-CRC signed', () {
expect(apolloKeepalivePacket, hex('A5 02 FD 5A'));
expect(apolloValidateFrame(apolloKeepalivePacket), isFalse);
});
});
group('PIN command', () {
test('builds AT+PWD[pin] with no line ending', () {
expect(String.fromCharCodes(buildApolloPinCommand('123456')), 'AT+PWD[123456]');
expect(buildApolloPinCommand('123456').last, ']'.codeUnitAt(0));
});
test('rejects non six-digit input', () {
expect(() => buildApolloPinCommand('12345'), throwsArgumentError);
expect(() => buildApolloPinCommand('12345a'), throwsArgumentError);
expect(() => buildApolloPinCommand('1234567'), throwsArgumentError);
});
});
group('PIN response parser', () {
Uint8List ascii(String s) => Uint8List.fromList(s.codeUnits);
test('OK+PWD:Y is success', () {
expect(parseApolloPinResponse(ascii('OK+PWD:Y')), AuthenticationResult.success);
});
test('OK+PWD:N is invalid credential', () {
expect(parseApolloPinResponse(ascii('OK+PWD:N')), AuthenticationResult.invalidCredential);
});
test('search within garbage', () {
expect(parseApolloPinResponse(ascii('garbageOK+PWD:Y\r\n')), AuthenticationResult.success);
});
test('NUL bytes are ignored', () {
expect(parseApolloPinResponse(ascii('OK+PWD\x00:Y')), AuthenticationResult.success);
});
test('partial is null', () {
expect(parseApolloPinResponse(ascii('OK+PW')), isNull);
expect(parseApolloPinResponse(ascii('')), isNull);
});
test('fragmented response reassembles through the AT buffer', () {
final buf = ApolloAtBuffer();
buf.add(ascii('OK+PW'));
expect(parseApolloPinResponse(buf.bytes), isNull);
buf.add(ascii('D:Y'));
expect(parseApolloPinResponse(buf.bytes), AuthenticationResult.success);
});
test('AT buffer is capped', () {
final buf = ApolloAtBuffer(maxLength: 16);
buf.add(Uint8List.fromList(List.filled(100, 0x41)));
expect(buf.bytes.length, 16);
});
});
group('frame lengths', () {
test('A5 02 is 4, other A5 is 8, AA/AB cmd 0/1 is 25', () {
expect(apolloFrameLength(0xA5, 0x02), 4);
expect(apolloFrameLength(0xA5, 0x01), 8);
expect(apolloFrameLength(0xAA, 0x00), 25);
expect(apolloFrameLength(0xAA, 0x01), 25);
expect(apolloFrameLength(0xAB, 0x00), 25);
expect(apolloFrameLength(0xAB, 0x01), 25);
expect(apolloFrameLength(0xAB, 0x02), isNull);
expect(apolloFrameLength(0x00, 0x00), isNull);
});
});
group('ApolloFrameBuffer', () {
late ApolloFrameBuffer buf;
setUp(() => buf = ApolloFrameBuffer());
test('one complete frame', () {
expect(buf.add(monitorFrame), [monitorFrame]);
expect(buf.length, 0);
});
test('frame split into two chunks', () {
expect(buf.add(monitorFrame.sublist(0, 10)), isEmpty);
expect(buf.add(monitorFrame.sublist(10)), [monitorFrame]);
});
test('frame split byte-by-byte', () {
final out = <Uint8List>[];
for (final b in monitorFrame) {
out.addAll(buf.add(Uint8List.fromList([b])));
}
expect(out, [monitorFrame]);
});
test('two frames in one notification', () {
final both = Uint8List.fromList([...monitorFrame, ...baseFrame]);
expect(buf.add(both), [monitorFrame, baseFrame]);
});
test('one complete plus half of next', () {
final data = Uint8List.fromList([...monitorFrame, ...baseFrame.sublist(0, 12)]);
expect(buf.add(data), [monitorFrame]);
expect(buf.add(baseFrame.sublist(12)), [baseFrame]);
});
test('garbage before a valid frame', () {
final data = Uint8List.fromList([0x00, 0x11, 0xFF, 0xAA, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('garbage between valid frames', () {
final data = Uint8List.fromList([...monitorFrame, 0x13, 0xAB, 0x00, ...baseFrame]);
expect(buf.add(data), [monitorFrame, baseFrame]);
});
test('bad CRC frame followed by a valid frame', () {
final bad = Uint8List.fromList(monitorFrame)..[24] ^= 0xFF;
final data = Uint8List.fromList([...bad, ...baseFrame]);
expect(buf.add(data), [baseFrame]);
});
test('unsupported head byte and unsupported command are skipped', () {
final data = Uint8List.fromList([0xCC, 0xAB, 0x07, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('exact keepalive is emitted, other A5 sequences are skipped', () {
expect(buf.add(apolloKeepalivePacket), [apolloKeepalivePacket]);
final data = Uint8List.fromList([0xA5, 0x02, 0x00, 0x00, ...monitorFrame]);
expect(buf.add(data), [monitorFrame]);
});
test('buffer size cap keeps the newest bytes', () {
final small = ApolloFrameBuffer(maxLength: 64);
small.add(Uint8List.fromList(List.filled(1000, 0x01)));
expect(small.length, lessThanOrEqualTo(64));
// Still recovers afterwards.
expect(small.add(monitorFrame), [monitorFrame]);
});
test('a garbage byte does not wedge parsing permanently', () {
buf.add(Uint8List.fromList([0xAA]));
buf.add(Uint8List.fromList([0x00]));
// 23 more junk bytes complete a fake 25-byte candidate with a bad CRC.
buf.add(Uint8List.fromList(List.filled(23, 0x55)));
expect(buf.add(monitorFrame), [monitorFrame]);
});
});
group('monitor parser', () {
test('synthetic vector, scaling flag true', () {
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: true);
expect(m.gear, 2);
expect(m.batteryLevel, 75);
expect(m.rawSpeed, 250);
expect(m.speed, 25.0);
expect(m.voltage, 48.0);
expect(m.current, 5.0);
expect(m.power, 240.0);
expect(m.motorTemperature, 35);
expect(m.controllerTemperature, 40);
expect(m.tripDistance, 12.3);
expect(m.odometer, 1234.5);
expect(m.atmosphereLight, isTrue);
expect(m.unlocked, isTrue);
expect(m.headlight, isTrue);
expect(m.rightTurnSignal, isFalse);
expect(m.leftTurnSignal, isFalse);
expect(m.cruiseControl, isTrue);
expect(m.imperial, isTrue);
expect(m.bootMode, isFalse);
});
test('scaling flag false gives raw/1000', () {
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: false);
expect(m.speed, 0.25);
});
test('speed uses the max of source A and B', () {
// A = 0x00FA (250), B = 0x00C8 (200) in the vector. Swap them.
final swapped = patched(monitorFrame, {6: 0x00, 7: 0xC8, 8: 0x00, 9: 0xFA});
expect(parseApolloMonitorFrame(swapped, internalSpeedScalingFlag: true).rawSpeed, 250);
final bBigger = patched(monitorFrame, {8: 0x01, 9: 0x00});
expect(parseApolloMonitorFrame(bBigger, internalSpeedScalingFlag: true).rawSpeed, 256);
});
test('negative current and derived power', () {
// -5.0 A = -320 = 0xFEC0
final f = patched(monitorFrame, {12: 0xFE, 13: 0xC0});
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
expect(m.current, -5.0);
expect(m.power, -240.0);
});
test('power is rounded to one decimal', () {
// voltage 48.1 (0x01E1), current 1/64 A = 0.015625 -> 0.7515625 -> 0.8
final f = patched(monitorFrame, {10: 0x01, 11: 0xE1, 12: 0x00, 13: 0x01});
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).power, 0.8);
});
test('negative temperatures', () {
final f = patched(monitorFrame, {14: 0xF6, 15: 0xEC}); // -10, -20
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
expect(m.motorTemperature, -10);
expect(m.controllerTemperature, -20);
});
test('24-bit odometer uses all three bytes', () {
final f = patched(monitorFrame, {18: 0x12, 19: 0x34, 20: 0x56});
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).odometer, 0x123456 / 10.0);
});
test('each switch flag is independent', () {
final base = patched(monitorFrame, {21: 0x00, 22: 0x00});
var m = parseApolloMonitorFrame(base, internalSpeedScalingFlag: true);
expect([m.atmosphereLight, m.unlocked, m.rightTurnSignal, m.leftTurnSignal, m.headlight,
m.cruiseControl, m.imperial, m.bootMode],
everyElement(isFalse));
m = parseApolloMonitorFrame(patched(base, {21: 1 << 1}), internalSpeedScalingFlag: true);
expect(m.atmosphereLight, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 3}), internalSpeedScalingFlag: true);
expect(m.unlocked, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 5}), internalSpeedScalingFlag: true);
expect(m.rightTurnSignal, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 6}), internalSpeedScalingFlag: true);
expect(m.leftTurnSignal, isTrue);
m = parseApolloMonitorFrame(patched(base, {21: 1 << 7}), internalSpeedScalingFlag: true);
expect(m.headlight, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 2}), internalSpeedScalingFlag: true);
expect(m.cruiseControl, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 5}), internalSpeedScalingFlag: true);
expect(m.imperial, isTrue);
m = parseApolloMonitorFrame(patched(base, {22: 1 << 6}), internalSpeedScalingFlag: true);
expect(m.bootMode, isTrue);
});
test('corrupt byte is rejected by CRC', () {
final bad = Uint8List.fromList(monitorFrame)..[4] = 0x03;
expect(() => parseApolloMonitorFrame(bad, internalSpeedScalingFlag: true),
throwsFormatException);
});
test('rejects wrong command or length', () {
expect(() => parseApolloMonitorFrame(baseFrame, internalSpeedScalingFlag: true),
throwsFormatException);
expect(() => parseApolloMonitorFrame(monitorFrame.sublist(1), internalSpeedScalingFlag: true),
throwsFormatException);
});
});
group('base parser', () {
test('synthetic vector', () {
final b = parseApolloBaseFrame(baseFrame);
expect(b.limitCruise, 25);
expect(b.limitMode1, 6);
expect(b.limitMode2, 12);
expect(b.limitMode3, 20);
expect(b.batteryTemperature, 30);
expect(b.totalBatteryCapacity, 10000);
expect(b.remainingBatteryCapacity, 5000);
expect(b.batteryCycles, 100);
expect(b.displayId, '12ab');
expect(b.displayVersion, 'V2.5.17');
expect(b.ctrlSn, isTrue);
expect(b.ctrlMp3, isTrue);
expect(b.ctrlRgb, isTrue);
expect(b.ctrlBms, isTrue);
expect(b.internalSpeedScalingFlag, isTrue);
expect(b.faultEnable, isTrue); // frame[8] = 0x80
expect(b.e9, isFalse);
});
test('negative battery temperature', () {
expect(parseApolloBaseFrame(patched(baseFrame, {7: 0xF1})).batteryTemperature, -15);
});
test('capacity fields are big-endian', () {
final b = parseApolloBaseFrame(patched(baseFrame, {12: 0x01, 13: 0x02, 14: 0x03, 15: 0x04, 16: 0x05, 17: 0x06}));
expect(b.totalBatteryCapacity, 0x0102);
expect(b.remainingBatteryCapacity, 0x0304);
expect(b.batteryCycles, 0x0506);
});
test('display id 0000 is absent, otherwise lowercase hex padded', () {
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0, 19: 0})).displayId, isNull);
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0x0A, 19: 0x0B})).displayId, '0a0b');
});
test('display version formats V%d.%d.%d', () {
expect(parseApolloBaseFrame(patched(baseFrame, {20: 0, 21: 10, 22: 255})).displayVersion, 'V0.10.255');
});
test('capability bits are independent', () {
final none = parseApolloBaseFrame(patched(baseFrame, {10: 0x00}));
expect([none.ctrlSn, none.ctrlMp3, none.ctrlRgb, none.ctrlBms, none.internalSpeedScalingFlag],
everyElement(isFalse));
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 0})).ctrlSn, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 1})).ctrlMp3, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 2})).ctrlRgb, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 3})).ctrlBms, isTrue);
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 4})).internalSpeedScalingFlag, isTrue);
});
test('fault flags, including the duplicated frame[8] bit 3 mapping', () {
final a = parseApolloBaseFrame(patched(baseFrame, {8: (1 << 1) | (1 << 2), 9: 0}));
expect(a.e9, isTrue);
expect(a.f1, isTrue);
expect(a.f2, isFalse);
expect(a.ctrlFaultEarlyWarning, isFalse);
expect(a.faultEnable, isFalse);
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning to bit 3.
final b = parseApolloBaseFrame(patched(baseFrame, {8: 1 << 3}));
expect(b.f2, isTrue);
expect(b.ctrlFaultEarlyWarning, isTrue);
final c = parseApolloBaseFrame(patched(baseFrame, {9: (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 7)}));
expect([c.e1, c.e2, c.e3, c.e4, c.e7], everyElement(isTrue));
final d = parseApolloBaseFrame(patched(baseFrame, {9: 0}));
expect([d.e1, d.e2, d.e3, d.e4, d.e7], everyElement(isFalse));
});
test('corrupt byte is rejected by CRC', () {
final bad = Uint8List.fromList(baseFrame)..[3] = 0x20;
expect(() => parseApolloBaseFrame(bad), throwsFormatException);
});
});
group('set-base packet builder', () {
Uint8List build({
int gear = 2,
bool headlight = true,
bool atmosphereLight = false,
bool cruiseControl = false,
bool bootMode = false,
bool imperial = true,
bool unlocked = false,
}) =>
buildApolloSetBasePacket(
gearPosition: gear,
headlight: headlight,
atmosphereLight: atmosphereLight,
cruiseControl: cruiseControl,
bootMode: bootMode,
imperial: imperial,
unlocked: unlocked,
limitCruise: 25,
limitMode1: 6,
limitMode2: 12,
limitMode3: 20,
);
test('exact packet for brief vector 1 (all zero)', () {
final p = buildApolloSetBasePacket(
gearPosition: 0,
headlight: false,
atmosphereLight: false,
cruiseControl: false,
bootMode: false,
imperial: false,
unlocked: false,
limitCruise: 0,
limitMode1: 0,
limitMode2: 0,
limitMode3: 0,
);
expect(p, hex('AB 00 0A 00 00 00 00 00 0B 6A'));
});
test('exact packet for brief vector 2 (unlocked with limits)', () {
final p = buildApolloSetBasePacket(
gearPosition: 0,
headlight: false,
atmosphereLight: false,
cruiseControl: false,
bootMode: false,
imperial: false,
unlocked: true,
limitCruise: 25,
limitMode1: 6,
limitMode2: 12,
limitMode3: 20,
);
expect(p, hex('AB 00 0A 80 19 06 0C 14 E8 E6'));
expect(apolloValidateFrame(p), isTrue);
});
test('is exactly 10 bytes with header AB 00 0A and the four limits', () {
final p = build();
expect(p.length, 10);
expect(p.sublist(0, 3), hex('AB 00 0A'));
expect(p.sublist(4, 8), [25, 6, 12, 20]);
});
/// Asserts that toggling one control changes exactly one FLAGS bit and
/// nothing else before the CRC.
void expectOnlyBitChanges(Uint8List a, Uint8List b, int bit) {
expect(a.sublist(0, 3), b.sublist(0, 3));
expect(a.sublist(4, 8), b.sublist(4, 8), reason: 'speed limits must be preserved');
expect(a[3] ^ b[3], 1 << bit, reason: 'only FLAGS bit $bit may change');
expect(apolloValidateFrame(a), isTrue);
expect(apolloValidateFrame(b), isTrue);
}
test('unlock changes only FLAGS bit 7', () {
expectOnlyBitChanges(build(unlocked: false), build(unlocked: true), 7);
});
test('headlight changes only FLAGS bit 2', () {
expectOnlyBitChanges(build(headlight: false), build(headlight: true), 2);
});
test('atmosphere light changes only FLAGS bit 3', () {
expectOnlyBitChanges(build(atmosphereLight: false), build(atmosphereLight: true), 3);
});
test('cruise changes only FLAGS bit 4', () {
expectOnlyBitChanges(build(cruiseControl: false), build(cruiseControl: true), 4);
});
test('boot mode bit 5 and imperial bit 6', () {
expectOnlyBitChanges(build(bootMode: false), build(bootMode: true), 5);
expectOnlyBitChanges(build(imperial: false), build(imperial: true), 6);
});
test('gear occupies bits 0-1 only', () {
expect(build(gear: 0)[3] & 0x03, 0);
expect(build(gear: 3)[3] & 0x03, 3);
expect(build(gear: 7)[3] & 0x03, 3);
});
test('rejects limits outside one byte', () {
expect(
() => buildApolloSetBasePacket(
gearPosition: 0, headlight: false, atmosphereLight: false, cruiseControl: false,
bootMode: false, imperial: false, unlocked: false,
limitCruise: 256, limitMode1: 0, limitMode2: 0, limitMode3: 0,
),
throwsArgumentError,
);
});
});
}