meshcore-open/test/models/model_changes_test.dart
2026-03-20 01:55:08 -07:00

361 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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));
});
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));
});
});
}