obd_controller_test.dart 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
  2. import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
  3. import 'package:cardash_launcher/data/models/gps_speed.dart';
  4. import 'package:cardash_launcher/data/models/location_reading.dart';
  5. import 'package:cardash_launcher/data/models/obd_state.dart';
  6. import 'package:cardash_launcher/data/repositories/settings_repository.dart';
  7. import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
  8. import 'package:cardash_launcher/features/home/obd_controller.dart';
  9. import 'package:flutter_test/flutter_test.dart';
  10. import 'package:shared_preferences/shared_preferences.dart';
  11. const _obdAddress = 'AA:BB:CC:DD:EE:01';
  12. const _phoneAddress = 'AA:BB:CC:DD:EE:02';
  13. const _obdAdapter = BluetoothDeviceInfo(
  14. name: 'ELM327 v1.5',
  15. address: _obdAddress,
  16. deviceClass: 0x1F00,
  17. );
  18. const _phone = BluetoothDeviceInfo(
  19. name: 'Pixel 8',
  20. address: _phoneAddress,
  21. deviceClass: 0x0200,
  22. );
  23. class _FakeTelemetry implements TelemetryPlatform {
  24. BluetoothPowerState state = BluetoothPowerState.on;
  25. List<BluetoothDeviceInfo> bonded = const [];
  26. bool permissionGranted = true;
  27. TelemetryException? connectError;
  28. ObdState obdState = const ObdState();
  29. int listCalls = 0;
  30. int stateCalls = 0;
  31. int connectCalls = 0;
  32. String? connectedAddress;
  33. @override
  34. Future<List<BluetoothDeviceInfo>> listBondedDevices() async {
  35. listCalls++;
  36. return bonded;
  37. }
  38. @override
  39. Future<BluetoothPowerState> bluetoothState() async {
  40. stateCalls++;
  41. return state;
  42. }
  43. @override
  44. Future<bool> ensureBluetoothPermission() async => permissionGranted;
  45. @override
  46. Future<bool> openBluetoothSettings() async => true;
  47. @override
  48. Future<bool> ensureLocationPermission() async => true;
  49. @override
  50. Future<LocationReading> getLocation() async =>
  51. const LocationReading(latitude: 0, longitude: 0, place: 'Home');
  52. @override
  53. Future<void> connectObd(String address) async {
  54. connectCalls++;
  55. connectedAddress = address;
  56. final problem = connectError;
  57. if (problem != null) throw problem;
  58. }
  59. @override
  60. Future<void> disconnectObd() async {}
  61. @override
  62. Future<ObdState> getObdState() async => obdState;
  63. @override
  64. Future<bool> startGpsSpeed() async => false;
  65. @override
  66. Future<void> stopGpsSpeed() async {}
  67. @override
  68. Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
  69. }
  70. Future<SettingsRepository> _settingsWithAdapter(String? address) async {
  71. SharedPreferences.setMockInitialValues({});
  72. final prefs = await SharedPreferences.getInstance();
  73. final settings = SettingsRepository(prefs);
  74. await settings.setObdAdapterAddress(address);
  75. return settings;
  76. }
  77. void main() {
  78. TestWidgetsFlutterBinding.ensureInitialized();
  79. test('classifies OBD adapters and non-OBD devices', () {
  80. expect(_obdAdapter.isLikelyObd, isTrue);
  81. expect(_phone.isLikelyObd, isFalse);
  82. const headset = BluetoothDeviceInfo(
  83. name: 'Car audio',
  84. address: 'AA:BB:CC:DD:EE:03',
  85. deviceClass: 0x0400,
  86. );
  87. expect(headset.isLikelyObd, isFalse);
  88. const sppDevice = BluetoothDeviceInfo(
  89. name: 'Unknown',
  90. address: 'AA:BB:CC:DD:EE:04',
  91. deviceClass: 0x0200,
  92. uuids: ['00001101-0000-1000-8000-00805f9b34fb'],
  93. );
  94. expect(sppDevice.isLikelyObd, isTrue);
  95. const unnamedClass = BluetoothDeviceInfo(
  96. name: 'Unknown',
  97. address: 'AA:BB:CC:DD:EE:05',
  98. );
  99. expect(unnamedClass.isLikelyObd, isTrue);
  100. const parsed = BluetoothDeviceInfo(
  101. name: 'Phone',
  102. address: _phoneAddress,
  103. deviceClass: 0x0200,
  104. );
  105. expect(parsed.majorClass, 0x0200);
  106. });
  107. test('splits paired devices into OBD and other lists', () async {
  108. final settings = await _settingsWithAdapter(null);
  109. final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter, _phone];
  110. final controller = ObdController(telemetry, settings);
  111. addTearDown(controller.dispose);
  112. expect(await controller.refreshDevices(force: true), isTrue);
  113. expect(controller.devices, hasLength(2));
  114. expect(controller.obdDevices.single.address, _obdAddress);
  115. expect(controller.otherDevices.single.address, _phoneAddress);
  116. });
  117. test('resume checks power state but does not relist known devices', () async {
  118. var now = DateTime(2026);
  119. final settings = await _settingsWithAdapter(null);
  120. final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter];
  121. final controller = ObdController(telemetry, settings, clock: () => now);
  122. addTearDown(controller.dispose);
  123. expect(await controller.refreshDevices(force: true), isTrue);
  124. expect(telemetry.listCalls, 1);
  125. expect(telemetry.stateCalls, 1);
  126. now = now.add(const Duration(seconds: 5));
  127. expect(await controller.refreshDevices(dueToResume: true), isTrue);
  128. expect(telemetry.listCalls, 1);
  129. expect(telemetry.stateCalls, 2);
  130. });
  131. test('turning on is reported without listing devices', () async {
  132. final settings = await _settingsWithAdapter(null);
  133. final telemetry = _FakeTelemetry()
  134. ..state = BluetoothPowerState.turningOn
  135. ..bonded = const [_obdAdapter];
  136. final controller = ObdController(telemetry, settings);
  137. addTearDown(controller.dispose);
  138. expect(await controller.refreshDevices(force: true), isFalse);
  139. expect(controller.bluetoothState, BluetoothPowerState.turningOn);
  140. expect(telemetry.listCalls, 0);
  141. expect(controller.statusLabel, 'Bluetooth is starting…');
  142. });
  143. test('denied Bluetooth permission clears the loading state', () async {
  144. final settings = await _settingsWithAdapter(null);
  145. final telemetry = _FakeTelemetry()..permissionGranted = false;
  146. final controller = ObdController(telemetry, settings);
  147. addTearDown(controller.dispose);
  148. expect(
  149. await controller.refreshDevices(requestPermission: true, force: true),
  150. isFalse,
  151. );
  152. expect(controller.loadingDevices, isFalse);
  153. expect(controller.needsBluetoothPermission, isTrue);
  154. expect(telemetry.listCalls, 0);
  155. });
  156. test('start does not auto-connect to a saved non-OBD device', () async {
  157. final settings = await _settingsWithAdapter(_phoneAddress);
  158. final telemetry = _FakeTelemetry()..bonded = const [_phone];
  159. final controller = ObdController(telemetry, settings)..initialize();
  160. addTearDown(controller.dispose);
  161. await controller.start();
  162. expect(telemetry.connectCalls, 0);
  163. expect(controller.error, contains('does not look like an OBD-II'));
  164. expect(controller.canRetry, isTrue);
  165. await controller.retryConnection();
  166. controller.stop();
  167. expect(telemetry.connectCalls, 1);
  168. });
  169. test('start connects a saved OBD adapter', () async {
  170. final settings = await _settingsWithAdapter(_obdAddress);
  171. final telemetry = _FakeTelemetry()..bonded = const [_obdAdapter];
  172. final controller = ObdController(telemetry, settings)..initialize();
  173. addTearDown(controller.dispose);
  174. await controller.start();
  175. controller.stop();
  176. expect(telemetry.connectCalls, 1);
  177. expect(telemetry.connectedAddress, _obdAddress);
  178. });
  179. test('connect timeout stops polling and exposes retry', () async {
  180. final settings = await _settingsWithAdapter(_obdAddress);
  181. final telemetry = _FakeTelemetry()
  182. ..bonded = const [_obdAdapter]
  183. ..connectError = TelemetryException(
  184. 'Bluetooth is not responding.',
  185. code: 'timeout',
  186. );
  187. final controller = ObdController(telemetry, settings)..initialize();
  188. addTearDown(controller.dispose);
  189. await controller.start();
  190. controller.stop();
  191. expect(telemetry.connectCalls, 1);
  192. expect(controller.canRetry, isTrue);
  193. expect(controller.statusLabel, 'Not connected — adapter not responding');
  194. });
  195. test('parses retryExhausted from the native snapshot', () {
  196. final state = ObdState.fromMap({
  197. 'connected': false,
  198. 'connecting': false,
  199. 'error': 'Could not connect.',
  200. 'retryExhausted': true,
  201. });
  202. expect(state.retryExhausted, isTrue);
  203. });
  204. test('parses the ECU fuel rate from the native snapshot', () {
  205. final state = ObdState.fromMap({
  206. 'connected': true,
  207. 'speedKmh': 60,
  208. 'maf': 12.0,
  209. 'fuelRateLph': 4.25,
  210. });
  211. expect(state.fuelRateLph, 4.25);
  212. });
  213. }