meshcore-open/test/models/model_changes_test.dart

362 lines
13 KiB
Dart
Raw Permalink Normal View History

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
2026-03-20 01:55:08 -07:00
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 1531 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));
});
2026-03-20 01:55:08 -07:00
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 = <String, dynamic>{};
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));
});
});
}