import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/models/path_history.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; // Builds a valid contact frame with the given pathLen and optional overrides. // Frame layout: [respCode(1)][pubKey(32)][type(1)][flags(1)][pathLen(1)][path(64)][name(32)][timestamp(4)][lat(4)][lon(4)] Uint8List _buildContactFrame({ int pathLen = 0, Uint8List? pubKey, String name = 'TestNode', }) { final writer = BytesBuilder(); writer.addByte(respCodeContact); // 3 writer.add( pubKey ?? Uint8List.fromList(List.generate(32, (i) => i + 1)), ); // valid pubkey writer.addByte(1); // type writer.addByte(0); // flags writer.addByte(pathLen); writer.add(Uint8List(64)); // path bytes (zeros) // name (32 bytes, null-padded) final nameBytes = Uint8List(32); final encoded = name.codeUnits; for (var i = 0; i < encoded.length && i < 31; i++) { nameBytes[i] = encoded[i]; } writer.add(nameBytes); // timestamp (4 bytes LE) - some nonzero value writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // lat, lon (4 bytes each) writer.add(Uint8List(4)); // lat writer.add(Uint8List(4)); // lon return Uint8List.fromList(writer.toBytes()); } void main() { group('Contact.fromFrame — pathLen mapping', () { test('pathLen == 0 → pathLength == 0 (direct, NOT flood)', () { final frame = _buildContactFrame(pathLen: 0); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.pathLength, equals(0)); }); test('pathLen == 1 → pathLength == 1', () { final frame = _buildContactFrame(pathLen: 1); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.pathLength, equals(1)); }); test('pathLen == 64 (maxPathSize) → pathLength == 64', () { final frame = _buildContactFrame(pathLen: maxPathSize); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.pathLength, equals(maxPathSize)); }); test('pathLen == 0xFF → pathLength == -1 (flood)', () { final frame = _buildContactFrame(pathLen: 0xFF); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.pathLength, equals(-1)); }); test('pathLen == 65 (over maxPathSize) → pathLength == -1 (flood)', () { final frame = _buildContactFrame(pathLen: 65); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.pathLength, equals(-1)); }); }); group('Contact.fromFrame — corrupt contact guards', () { test('all-zero public key → returns null', () { final zeroPubKey = Uint8List(32); // all zeros final frame = _buildContactFrame(pubKey: zeroPubKey); final contact = Contact.fromFrame(frame); expect(contact, isNull); }); test('mostly-zero public key (>16 zeros out of 32) → returns null', () { // 17 zeros out of 32 bytes exceeds pubKeySize ~/ 2 == 16 final pubKey = Uint8List(32); pubKey[0] = 0xAB; pubKey[1] = 0xCD; pubKey[2] = 0xEF; pubKey[3] = 0x12; pubKey[4] = 0x34; pubKey[5] = 0x56; pubKey[6] = 0x78; pubKey[7] = 0x9A; pubKey[8] = 0xBC; pubKey[9] = 0xDE; pubKey[10] = 0xF0; pubKey[11] = 0x11; pubKey[12] = 0x22; pubKey[13] = 0x33; pubKey[14] = 0x44; // bytes 15–31 are zero: that is 17 zeros (indices 15..31 inclusive) final frame = _buildContactFrame(pubKey: pubKey); final contact = Contact.fromFrame(frame); expect(contact, isNull); }); test('valid public key (few zeros) → returns Contact', () { // Only 1 zero → well below the threshold final pubKey = Uint8List.fromList(List.generate(32, (i) => i + 1)); pubKey[5] = 0; // one zero byte final frame = _buildContactFrame(pubKey: pubKey); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); }); test('name with all non-printable characters → returns null', () { // Build frame with a name composed entirely of control characters (< 0x20) final nameBytes = Uint8List(32); nameBytes[0] = 0x01; nameBytes[1] = 0x02; nameBytes[2] = 0x03; // remaining are 0x00 (null terminator ends the string after index 2, // so readCStringGreedy returns a 3-char string of non-printables) final writer = BytesBuilder(); writer.addByte(respCodeContact); writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1))); writer.addByte(1); // type writer.addByte(0); // flags writer.addByte(0); // pathLen writer.add(Uint8List(64)); // path writer.add(nameBytes); writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp writer.add(Uint8List(4)); // lat writer.add(Uint8List(4)); // lon final frame = Uint8List.fromList(writer.toBytes()); final contact = Contact.fromFrame(frame); expect(contact, isNull); }); test('name with valid printable characters → returns Contact', () { final frame = _buildContactFrame(name: 'Alice'); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); expect(contact!.name, equals('Alice')); }); test( 'name with mix of printable and replacement chars → returns Contact (not all bad)', () { // Build a name with mostly printable chars and one replacement char (0xFFFD in codeUnits). // utf8 allowMalformed: true maps invalid sequences to U+FFFD. // We embed one invalid UTF-8 byte (0x80) among valid ASCII bytes. // The decoded string will be "Hi\uFFFDThere" — not ALL bad, so should be accepted. final nameBytes = Uint8List(32); nameBytes[0] = 0x48; // 'H' nameBytes[1] = 0x69; // 'i' nameBytes[2] = 0x80; // invalid UTF-8 → decoded as U+FFFD nameBytes[3] = 0x54; // 'T' nameBytes[4] = 0x68; // 'h' nameBytes[5] = 0x65; // 'e' nameBytes[6] = 0x72; // 'r' nameBytes[7] = 0x65; // 'e' // rest are 0x00 (null terminator) final writer = BytesBuilder(); writer.addByte(respCodeContact); writer.add(Uint8List.fromList(List.generate(32, (i) => i + 1))); writer.addByte(1); // type writer.addByte(0); // flags writer.addByte(0); // pathLen writer.add(Uint8List(64)); // path writer.add(nameBytes); writer.add(Uint8List.fromList([0x01, 0x00, 0x00, 0x00])); // timestamp writer.add(Uint8List(4)); // lat writer.add(Uint8List(4)); // lon final frame = Uint8List.fromList(writer.toBytes()); final contact = Contact.fromFrame(frame); expect(contact, isNotNull); }, ); }); group('PathRecord — routeWeight field', () { test('default routeWeight is 1.0', () { final record = PathRecord( hopCount: 2, tripTimeMs: 500, timestamp: DateTime(2024), wasFloodDiscovery: false, pathBytes: [0x01, 0x02], successCount: 1, failureCount: 0, ); expect(record.routeWeight, equals(1.0)); }); test('custom routeWeight is preserved', () { final record = PathRecord( hopCount: 3, tripTimeMs: 800, timestamp: DateTime(2024), wasFloodDiscovery: false, pathBytes: [0x01], successCount: 5, failureCount: 2, routeWeight: 3.5, ); expect(record.routeWeight, equals(3.5)); }); test('toJson includes route_weight', () { final record = PathRecord( hopCount: 1, tripTimeMs: 200, timestamp: DateTime(2024), wasFloodDiscovery: true, pathBytes: [], successCount: 0, failureCount: 0, routeWeight: 2.25, ); final json = record.toJson(); expect(json.containsKey('route_weight'), isTrue); expect(json['route_weight'], equals(2.25)); }); test('fromJson reads route_weight', () { final json = { 'hop_count': 2, 'trip_time_ms': 400, 'timestamp': DateTime(2024).toIso8601String(), 'was_flood': false, 'path_bytes': [1, 2, 3], 'success_count': 3, 'failure_count': 1, 'route_weight': 4.0, }; final record = PathRecord.fromJson(json); expect(record.routeWeight, equals(4.0)); }); test( 'fromJson with missing route_weight defaults to 1.0 (backward compat)', () { final json = { 'hop_count': 1, 'trip_time_ms': 100, 'timestamp': DateTime(2024).toIso8601String(), 'was_flood': false, 'path_bytes': [], 'success_count': 0, 'failure_count': 0, // 'route_weight' intentionally omitted }; final record = PathRecord.fromJson(json); expect(record.routeWeight, equals(1.0)); }, ); }); group('AppSettings — new fields', () { test('default values are correct', () { final settings = AppSettings(); expect(settings.maxRouteWeight, equals(5.0)); expect(settings.initialRouteWeight, equals(3.0)); expect(settings.routeWeightSuccessIncrement, equals(0.5)); expect(settings.routeWeightFailureDecrement, equals(0.2)); expect(settings.maxMessageRetries, equals(5)); }); test('toJson includes all new fields', () { final settings = AppSettings(); final json = settings.toJson(); expect(json.containsKey('max_route_weight'), isTrue); expect(json.containsKey('initial_route_weight'), isTrue); expect(json.containsKey('route_weight_success_increment'), isTrue); expect(json.containsKey('route_weight_failure_decrement'), isTrue); expect(json.containsKey('max_message_retries'), isTrue); expect(json['max_route_weight'], equals(5.0)); expect(json['initial_route_weight'], equals(3.0)); expect(json['route_weight_success_increment'], equals(0.5)); expect(json['route_weight_failure_decrement'], equals(0.2)); expect(json['max_message_retries'], equals(5)); }); test('fromJson reads all new fields', () { final json = { 'max_route_weight': 10.0, 'initial_route_weight': 2.0, 'route_weight_success_increment': 1.0, 'route_weight_failure_decrement': 1.5, 'max_message_retries': 8, }; final settings = AppSettings.fromJson(json); expect(settings.maxRouteWeight, equals(10.0)); expect(settings.initialRouteWeight, equals(2.0)); expect(settings.routeWeightSuccessIncrement, equals(1.0)); expect(settings.routeWeightFailureDecrement, equals(1.5)); expect(settings.maxMessageRetries, equals(8)); }); test( 'fromJson with missing new fields uses defaults (backward compat)', () { // Simulate an old settings JSON with none of the new fields final json = {}; final settings = AppSettings.fromJson(json); expect(settings.maxRouteWeight, equals(5.0)); expect(settings.initialRouteWeight, equals(3.0)); expect(settings.routeWeightSuccessIncrement, equals(0.5)); expect(settings.routeWeightFailureDecrement, equals(0.2)); expect(settings.maxMessageRetries, equals(5)); }, ); test('copyWith works for maxRouteWeight', () { final settings = AppSettings(); final updated = settings.copyWith(maxRouteWeight: 8.0); expect(updated.maxRouteWeight, equals(8.0)); // Other fields should be unchanged expect(updated.initialRouteWeight, equals(settings.initialRouteWeight)); expect(updated.maxMessageRetries, equals(settings.maxMessageRetries)); }); test('copyWith works for initialRouteWeight', () { final settings = AppSettings(); final updated = settings.copyWith(initialRouteWeight: 3.0); expect(updated.initialRouteWeight, equals(3.0)); expect(updated.maxRouteWeight, equals(settings.maxRouteWeight)); }); test('copyWith works for routeWeightSuccessIncrement', () { final settings = AppSettings(); final updated = settings.copyWith(routeWeightSuccessIncrement: 0.25); expect(updated.routeWeightSuccessIncrement, equals(0.25)); expect( updated.routeWeightFailureDecrement, equals(settings.routeWeightFailureDecrement), ); }); test('copyWith works for routeWeightFailureDecrement', () { final settings = AppSettings(); final updated = settings.copyWith(routeWeightFailureDecrement: 0.75); expect(updated.routeWeightFailureDecrement, equals(0.75)); expect( updated.routeWeightSuccessIncrement, equals(settings.routeWeightSuccessIncrement), ); }); test('copyWith works for maxMessageRetries', () { final settings = AppSettings(); final updated = settings.copyWith(maxMessageRetries: 10); expect(updated.maxMessageRetries, equals(10)); expect(updated.maxRouteWeight, equals(settings.maxRouteWeight)); }); }); }