import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:hvbt_dash/core/protocol/neg8.dart'; import 'package:hvbt_dash/core/protocol/s300_parser.dart'; import 'package:hvbt_dash/core/protocol/temp_table.dart'; /// Builds a zeroed 128-byte S300 frame and stamps the NEG8 checksum at byte 127. Uint8List _buildS300Frame({ int rpm = 0, int vss = 0, int mapRaw = 0, int tps = 0, int inj = 0, int ign = 0, int ect = 0x80, // tempXlt[0x80]=73 → 73-40=33°C int iat = 0x80, int bat = 0, int sw1 = 0, int sw2 = 0, int sw3 = 0, int krtrd = 0, int gear = 0, }) { final frame = Uint8List(128); // Header frame[0] = 0x1B; frame[1] = 0x00; // RPM: bytes +3..4, little-endian, raw frame[3] = rpm & 0xFF; frame[4] = (rpm >> 8) & 0xFF; // VSS: bytes +5..6, little-endian frame[5] = vss & 0xFF; frame[6] = (vss >> 8) & 0xFF; // MAP: bytes +7..8, little-endian frame[7] = mapRaw & 0xFF; frame[8] = (mapRaw >> 8) & 0xFF; // TPS: byte +9 frame[9] = tps; // INJ: bytes +10..11, BE frame[10] = (inj >> 8) & 0xFF; frame[11] = inj & 0xFF; // IGN: byte +12 frame[12] = ign; // KRtrd: byte +13 frame[13] = krtrd; // SW bitmaps frame[0x11] = sw1; frame[0x12] = sw2; frame[0x13] = sw3; // Gear: decimal offset 27 frame[27] = gear; // ECT: decimal offset 33 frame[33] = ect; // IAT: decimal offset 34 frame[34] = iat; // BAT: decimal offset 36 frame[36] = bat; // Stamp NEG8 checksum at byte 127 int neg8 = 0; for (int i = 0; i < 127; i++) { neg8 = (neg8 - frame[i]) & 0xFF; } frame[127] = neg8; return frame; } void main() { group('S300 parser', () { test('parses RPM correctly', () { // RPM raw = 3000 final frame = _buildS300Frame(rpm: 3000); final state = parseS300(frame); expect(state.rpm, equals(3000.0)); }); test('parses VSS = 0 when raw < 893', () { final frame = _buildS300Frame(vss: 500); final state = parseS300(frame); expect(state.vss, equals(0.0)); }); test('parses VSS correctly when raw >= 893', () { // vss = 228480 / 1000 = 228.48 km/h final frame = _buildS300Frame(vss: 1000); final state = parseS300(frame); expect(state.vss, closeTo(228.48, 0.01)); }); test('parses MAP as raw/10 kPa', () { // mapRaw = 1148 -> 114.8 kPa final frame = _buildS300Frame(mapRaw: 1148); final state = parseS300(frame); expect(state.map, closeTo(114.8, 0.01)); }); test('parses TPS = 0 when raw = 0', () { final frame = _buildS300Frame(tps: 0); final state = parseS300(frame); expect(state.tps, equals(0.0)); }); test('parses TPS = 100 when raw = 255', () { final frame = _buildS300Frame(tps: 255); final state = parseS300(frame); expect(state.tps, equals(100.0)); }); test('parses TPS correctly when raw >= 25', () { // tps raw=127 -> 127*100/255 ≈ 49.8% (confirmed against live capture) final frame = _buildS300Frame(tps: 127); final state = parseS300(frame); expect(state.tps, closeTo(49.8, 0.1)); }); test('parses Gear from decimal offset 27', () { final frame = _buildS300Frame(gear: 5); final state = parseS300(frame); expect(state.gear, equals(5)); }); test('parses ECT using temp lookup table', () { // Confirmed via live capture: raw=0xD2(210) matched the reference // tool's 28.4°F exactly at the same instant (RPM cross-checked). // ect raw = 0xD2 = 210 → tempXlt[210]=38 → 38-40=-2°C final frame = _buildS300Frame(ect: 0xD2); final state = parseS300(frame); expect(state.ect, equals((tempXlt[0xD2] - 40).toDouble())); }); test('parses IAT using temp lookup table', () { // Confirmed via the same live capture: raw=0xCA(202) was within // ~1.3°F of the reference tool's 36.1°F reading. // iat raw = 0xCA = 202 → tempXlt[202]=43 → 43-40=3°C final frame = _buildS300Frame(iat: 0xCA); final state = parseS300(frame); expect(state.iat, equals((tempXlt[0xCA] - 40).toDouble())); }); test('parses BAT = raw * 26 / 270', () { // bat raw = 135 → 135*26/270 = 13.0 V final frame = _buildS300Frame(bat: 135); final state = parseS300(frame); expect(state.bat, closeTo(13.0, 0.01)); }); test('IGN decode: (raw + 120) / 2', () { // ign raw = 164 -> (164 + 120) / 2 = 142.0 final frame = _buildS300Frame(ign: 164); final state = parseS300(frame); expect(state.ign, equals(142.0)); }); test('MIL flag set from SW2 bit5', () { final frame = _buildS300Frame(sw2: 0x20); // bit5 set final state = parseS300(frame); expect(state.mil, isTrue); }); test('fuel pump flag is active-low: set when SW2 bit2 is CLEAR', () { // SW2 bit 2 is the "Fuel" line in the reference tool's own channel // list — confirmed live to be the fuel PUMP relay status, not fuel // cut, but also confirmed live to be active-low: an extended session // held RPM above the ~300 threshold where the pump should be on, and // this bit read 0 for the entire session. Separately confirmed the // app showed "on" below ~200 rpm and "off" above it — exactly // inverted from a naive `!= 0` read, hence bit CLEAR = pump on. final frame = _buildS300Frame(sw2: 0x38); // bit2 clear, other bits set final state = parseS300(frame); expect(state.fuelPump, isTrue); }); test('fuel pump flag clear (pump off) when SW2 bit2 is set', () { final frame = _buildS300Frame(sw2: 0x04); // bit2 set final state = parseS300(frame); expect(state.fuelPump, isFalse); }); test('knock flag set when krtrd > 0', () { final frame = _buildS300Frame(krtrd: 5); final state = parseS300(frame); expect(state.knock, isTrue); }); test('knock flag clear when krtrd = 0', () { final frame = _buildS300Frame(krtrd: 0); final state = parseS300(frame); expect(state.knock, isFalse); }); test('throws on wrong frame length', () { expect( () => parseS300(Uint8List(64)), throwsArgumentError, ); }); test('throws on bad checksum', () { final frame = _buildS300Frame(rpm: 1000); frame[127] = 0x00; // corrupt checksum expect(() => parseS300(frame), throwsArgumentError); }); test('can parse bad checksum frame when validation is disabled', () { final frame = _buildS300Frame(rpm: 1000); frame[127] = 0x00; // corrupt checksum final state = parseS300(frame, validateChecksum: false); expect(state.rpm, equals(1000.0)); }); test('decodes RPM=0 for a frame matching the fixed event-frame template', () { // parseS300 itself doesn't special-case this template — it just reads // the normal offsets, which land on the template's fixed bytes. // Holding the last known-good value across this frame is the caller's // job (sensor_provider.dart), driven by isS300TemplateFrame below. final frame = _templateFrame(); final state = parseS300(frame); expect(state.rpm, equals(0.0)); }); test('isS300TemplateFrame detects the fixed event-frame byte pattern', () { // Verified via live A/B capture: this exact bytes-0-16 pattern shows // up whenever SW2 (byte 18, outside this span) reports fuel cut // active, corroborated independently by the same frame's INJ pulse // width reading 0 and TPS reading a closed-throttle value. expect(isS300TemplateFrame(_templateFrame()), isTrue); }); test('isS300TemplateFrame is false for a normal live-reading frame', () { final frame = _buildS300Frame(rpm: 3000, tps: 127); expect(isS300TemplateFrame(frame), isFalse); }); }); } /// Builds a 128-byte frame stamped with the S300's fixed event-frame /// template in bytes 0-16, with a valid NEG8 checksum. Uint8List _templateFrame() { final frame = Uint8List(128); final prefix = [ 0x1B, 0x00, 0x14, 0x00, 0x00, 0xFF, 0xFF, 0xF2, 0x03, 0x18, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0xC4, ]; for (var i = 0; i < prefix.length; i++) { frame[i] = prefix[i]; } frame[127] = calculateNeg8(frame.sublist(0, 127)); return frame; }