mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-17 16:06:28 +10:00
Msg Retry fixes, channel message fixes. Notification fixes. Make more desktop friendly. Enhance retry algo. Fix predicted location clustering add retries to reactions and fix the reactions in private DMS centralize and cleanup code in var areas
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/helpers/path_helper.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
|
||||
Contact _contact({
|
||||
required int firstByte,
|
||||
required String name,
|
||||
required int type,
|
||||
}) {
|
||||
final key = Uint8List(32)..[0] = firstByte;
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
name: name,
|
||||
type: type,
|
||||
pathLength: 0,
|
||||
path: Uint8List(0),
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () {
|
||||
final contacts = [
|
||||
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
|
||||
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
|
||||
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom),
|
||||
];
|
||||
|
||||
final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts);
|
||||
|
||||
expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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 = <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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
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/path_selection.dart';
|
||||
import 'package:meshcore_open/services/path_history_service.dart';
|
||||
import 'package:meshcore_open/services/storage_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake storage — no SharedPreferences dependency, all in-memory.
|
||||
// ---------------------------------------------------------------------------
|
||||
class FakeStorageService extends StorageService {
|
||||
final Map<String, ContactPathHistory> _store = {};
|
||||
|
||||
@override
|
||||
Future<void> savePathHistory(
|
||||
String contactPubKeyHex,
|
||||
ContactPathHistory history,
|
||||
) async {
|
||||
_store[contactPubKeyHex] = history;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
|
||||
return _store[contactPubKeyHex];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPathHistory(String contactPubKeyHex) async {
|
||||
_store.remove(contactPubKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal Contact with the given pubKeyHex, pathLength, and path.
|
||||
///
|
||||
/// [publicKeyHex] must be exactly 64 hex characters (32 bytes).
|
||||
Contact _makeContact({
|
||||
required String publicKeyHex,
|
||||
int pathLength = -1,
|
||||
List<int> path = const [],
|
||||
}) {
|
||||
assert(publicKeyHex.length == 64, 'publicKeyHex must be 64 chars');
|
||||
final bytes = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
bytes[i] = int.parse(publicKeyHex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return Contact(
|
||||
publicKey: bytes,
|
||||
name: 'Test',
|
||||
type: 1,
|
||||
pathLength: pathLength,
|
||||
path: Uint8List.fromList(path),
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// A 64-char hex string derived from a short tag (padded with zeros).
|
||||
String _hex(String tag) {
|
||||
// Convert tag to hex-safe characters, then pad
|
||||
final hexTag = tag.codeUnits
|
||||
.map((c) => c.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return hexTag.padLeft(64, '0');
|
||||
}
|
||||
|
||||
/// Flush the microtask / async queue so that deferred storage loads complete.
|
||||
Future<void> _flush() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
/// Seed the service's cache for [pubKeyHex] by adding one path record and
|
||||
/// waiting for the async storage-load path to complete.
|
||||
///
|
||||
/// Call this before making synchronous assertions on a contact that has never
|
||||
/// been seen by the service.
|
||||
Future<void> _seed(
|
||||
PathHistoryService svc,
|
||||
String pubKeyHex, {
|
||||
List<int> pathBytes = const [1],
|
||||
int hopCount = 1,
|
||||
double weight = 1.0,
|
||||
}) async {
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKeyHex,
|
||||
pathLength: hopCount,
|
||||
path: pathBytes,
|
||||
);
|
||||
svc.handlePathUpdated(contact, initialWeight: weight);
|
||||
await _flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
late FakeStorageService storage;
|
||||
late PathHistoryService svc;
|
||||
|
||||
setUp(() {
|
||||
storage = FakeStorageService();
|
||||
svc = PathHistoryService(storage);
|
||||
});
|
||||
|
||||
group('path selection', () {
|
||||
test('empty path history returns flood', () {
|
||||
const pubKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(selection.useFlood, isTrue);
|
||||
});
|
||||
|
||||
test('returns flood when maxRetries == 0', () {
|
||||
const pubKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 0,
|
||||
);
|
||||
expect(selection.useFlood, isTrue);
|
||||
});
|
||||
|
||||
test('single known path is used for non-final attempts', () async {
|
||||
final pubKey = _hex('aabb');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01, 0x02], hopCount: 2);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final selection = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: i,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(selection.useFlood, isFalse, reason: 'attempt $i should be path');
|
||||
expect(selection.pathBytes, equals([0x01, 0x02]));
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'retries avoid immediately repeating the same path when possible',
|
||||
() async {
|
||||
final pubKey = _hex('rot1');
|
||||
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xBB], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 1,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first],
|
||||
);
|
||||
|
||||
expect(first.useFlood, isFalse);
|
||||
expect(second.useFlood, isFalse);
|
||||
expect(second.pathBytes, isNot(equals(first.pathBytes)));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'retries avoid the last two paths when a third option exists',
|
||||
() async {
|
||||
final pubKey = _hex('rot2');
|
||||
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 3.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 1.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 1,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first],
|
||||
);
|
||||
final third = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 2,
|
||||
maxRetries: 5,
|
||||
recentSelections: [first, second],
|
||||
);
|
||||
|
||||
final chosenPaths = [
|
||||
first.pathBytes,
|
||||
second.pathBytes,
|
||||
third.pathBytes,
|
||||
];
|
||||
expect(
|
||||
chosenPaths
|
||||
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
|
||||
.toSet()
|
||||
.length,
|
||||
equals(3),
|
||||
);
|
||||
expect(
|
||||
chosenPaths,
|
||||
everyElement(anyOf(equals([0xA1]), equals([0xB2]), equals([0xC3]))),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('first-attempt selection rotates across ranked candidates', () async {
|
||||
final pubKey = _hex('rot3');
|
||||
await _seed(svc, pubKey, pathBytes: [0xA1], hopCount: 1, weight: 4.0);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xB2], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 1.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0xC3], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final second = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
final third = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
|
||||
expect(first.pathBytes, isNot(equals(second.pathBytes)));
|
||||
expect(second.pathBytes, isNot(equals(third.pathBytes)));
|
||||
expect(
|
||||
[first.pathBytes, second.pathBytes, third.pathBytes]
|
||||
.map((path) => path.map((b) => b.toRadixString(16)).join(','))
|
||||
.toSet()
|
||||
.length,
|
||||
equals(3),
|
||||
);
|
||||
});
|
||||
|
||||
test('final attempt is always flood regardless of known paths', () async {
|
||||
final pubKey = _hex('ef01');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1);
|
||||
|
||||
for (final retries in [1, 2, 5, 10]) {
|
||||
final lastAttempt = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: retries - 1,
|
||||
maxRetries: retries,
|
||||
);
|
||||
expect(
|
||||
lastAttempt.useFlood,
|
||||
isTrue,
|
||||
reason: 'maxRetries=$retries: last attempt must be flood',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('path scoring', () {
|
||||
test('higher reliability beats higher route weight', () async {
|
||||
final pubKey = _hex('rank1');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.5);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x02], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x02]));
|
||||
});
|
||||
|
||||
test('lower latency wins when reliability is tied', () async {
|
||||
final pubKey = _hex('rank2');
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x10], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
tripTimeMs: 1200,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x20], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
tripTimeMs: 400,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x20]));
|
||||
});
|
||||
|
||||
test('fresher path wins when reliability and latency are tied', () async {
|
||||
final pubKey = _hex('rank3');
|
||||
final oldTimestamp = DateTime.now().subtract(const Duration(days: 10));
|
||||
final newTimestamp = DateTime.now().subtract(const Duration(hours: 1));
|
||||
storage._store[pubKey] = ContactPathHistory(
|
||||
contactPubKeyHex: pubKey,
|
||||
recentPaths: [
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 900,
|
||||
timestamp: oldTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x01],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 900,
|
||||
timestamp: newTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x02],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
],
|
||||
);
|
||||
svc.getRecentPaths(pubKey);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x02]));
|
||||
});
|
||||
|
||||
test('higher route weight wins when other factors are effectively tied', () async {
|
||||
final pubKey = _hex('rank4');
|
||||
final sharedTimestamp =
|
||||
DateTime.now().subtract(const Duration(minutes: 30));
|
||||
storage._store[pubKey] = ContactPathHistory(
|
||||
contactPubKeyHex: pubKey,
|
||||
recentPaths: [
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 750,
|
||||
timestamp: sharedTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x01],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 4.0,
|
||||
),
|
||||
PathRecord(
|
||||
hopCount: 1,
|
||||
tripTimeMs: 750,
|
||||
timestamp: sharedTimestamp,
|
||||
wasFloodDiscovery: false,
|
||||
pathBytes: const [0x02],
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
routeWeight: 1.0,
|
||||
),
|
||||
],
|
||||
);
|
||||
svc.getRecentPaths(pubKey);
|
||||
await _flush();
|
||||
|
||||
final first = svc.selectPathForAttempt(
|
||||
pubKey,
|
||||
attemptIndex: 0,
|
||||
maxRetries: 5,
|
||||
);
|
||||
expect(first.pathBytes, equals([0x01]));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 3: recordPathResult — weight adjustment
|
||||
// -------------------------------------------------------------------------
|
||||
group('recordPathResult weight adjustment', () {
|
||||
test('success increments weight by successIncrement', () async {
|
||||
final pubKey = _hex('w001');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(paths.first.timestamp, isNotNull);
|
||||
});
|
||||
|
||||
test('attempts do not set timestamp before first success', () async {
|
||||
final pubKey = _hex('w000');
|
||||
|
||||
svc.recordPathAttempt(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.successCount, equals(0));
|
||||
expect(paths.first.timestamp, isNull);
|
||||
});
|
||||
|
||||
test('failure preserves the last success timestamp', () async {
|
||||
final pubKey = _hex('w006');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.0,
|
||||
);
|
||||
await _flush();
|
||||
final successTimestamp = svc.getRecentPaths(pubKey).first.timestamp;
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.1,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.timestamp, equals(successTimestamp));
|
||||
});
|
||||
|
||||
test('success clamps at maxWeight', () async {
|
||||
final pubKey = _hex('w002');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: true,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
|
||||
});
|
||||
|
||||
test('failure decrements weight', () async {
|
||||
final pubKey = _hex('w003');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 2.0);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.5,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
});
|
||||
|
||||
test('failure to 0 removes the path', () async {
|
||||
final pubKey = _hex('w004');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 0.3);
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [0x01], hopCount: 1, useFlood: false),
|
||||
success: false,
|
||||
failureDecrement: 0.5, // 0.3 - 0.5 = -0.2 → remove
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(
|
||||
paths.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0x01),
|
||||
isFalse,
|
||||
reason: 'path with weight <= 0 should have been removed',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'flood result does not affect path records, updates floodStats',
|
||||
() async {
|
||||
final pubKey = _hex('w005');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
success: true,
|
||||
tripTimeMs: 1234,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Path records should be unchanged.
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
|
||||
// Flood stats should be updated.
|
||||
final stats = svc.getFloodStats(pubKey);
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.successCount, equals(1));
|
||||
expect(stats.lastTripTimeMs, equals(1234));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 4: handlePathUpdated
|
||||
// -------------------------------------------------------------------------
|
||||
group('handlePathUpdated', () {
|
||||
test(
|
||||
'pathLength >= 0 with path bytes → records path using pathLength',
|
||||
() async {
|
||||
final pubKey = _hex('h001');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: 3,
|
||||
path: [0x01, 0x02, 0x03],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
expect(paths.first.hopCount, equals(3));
|
||||
expect(paths.first.pathBytes, equals([0x01, 0x02, 0x03]));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pathLength < 0 with path bytes → records path using path.length as hopCount',
|
||||
() async {
|
||||
final pubKey = _hex('h002');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: -1, // flood indicator from firmware
|
||||
path: [0xAA, 0xBB],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isNotEmpty);
|
||||
// hopCount should equal path.length (2), not pathLength (-1).
|
||||
expect(paths.first.hopCount, equals(2));
|
||||
expect(paths.first.pathBytes, equals([0xAA, 0xBB]));
|
||||
},
|
||||
);
|
||||
|
||||
test('pathLength < 0 with empty path → skipped (returns early)', () async {
|
||||
final pubKey = _hex('h003');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: -1,
|
||||
path: [],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact);
|
||||
await _flush();
|
||||
|
||||
// Nothing should have been recorded.
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths, isEmpty);
|
||||
});
|
||||
|
||||
test('initialWeight is applied to the new record', () async {
|
||||
final pubKey = _hex('h004');
|
||||
final contact = _makeContact(
|
||||
publicKeyHex: pubKey,
|
||||
pathLength: 1,
|
||||
path: [0x55],
|
||||
);
|
||||
|
||||
svc.handlePathUpdated(contact, initialWeight: 2.5);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(2.5, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Group 5: recordFloodPathAttribution
|
||||
// -------------------------------------------------------------------------
|
||||
group('recordFloodPathAttribution', () {
|
||||
test('credits existing path with success increment', () async {
|
||||
final pubKey = _hex('fa01');
|
||||
await _seed(
|
||||
svc,
|
||||
pubKey,
|
||||
pathBytes: [0x01, 0x02],
|
||||
hopCount: 2,
|
||||
weight: 1.0,
|
||||
);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01, 0x02],
|
||||
hopCount: 2,
|
||||
tripTimeMs: 3000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
final credited = paths.firstWhere(
|
||||
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0x01,
|
||||
);
|
||||
expect(credited.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(credited.successCount, equals(1));
|
||||
expect(credited.tripTimeMs, equals(3000));
|
||||
});
|
||||
|
||||
test('creates new path record when path is unknown', () async {
|
||||
final pubKey = _hex('fa02');
|
||||
// Seed with a different path so the cache is warm.
|
||||
await _seed(svc, pubKey, pathBytes: [0xAA], hopCount: 1, weight: 1.0);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0xBB, 0xCC],
|
||||
hopCount: 2,
|
||||
tripTimeMs: 2000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
final newPath = paths.firstWhere(
|
||||
(p) => p.pathBytes.length == 2 && p.pathBytes[0] == 0xBB,
|
||||
);
|
||||
// New path: weight = 1.0 (default) + 0.5 = 1.5
|
||||
expect(newPath.routeWeight, closeTo(1.5, 0.001));
|
||||
expect(newPath.successCount, equals(1));
|
||||
expect(newPath.wasFloodDiscovery, isTrue);
|
||||
});
|
||||
|
||||
test('clamps weight at maxWeight', () async {
|
||||
final pubKey = _hex('fa03');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 4.8);
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: 1,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(5.0, 0.001));
|
||||
});
|
||||
|
||||
test('ignores empty pathBytes', () async {
|
||||
final pubKey = _hex('fa04');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [],
|
||||
hopCount: 0,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Existing path should be untouched.
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
});
|
||||
|
||||
test('ignores negative hopCount (flood indicator)', () async {
|
||||
final pubKey = _hex('fa05');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
final pathsBefore = svc.getRecentPaths(pubKey);
|
||||
final weightBefore = pathsBefore.first.routeWeight;
|
||||
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: -1,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
final pathsAfter = svc.getRecentPaths(pubKey);
|
||||
expect(pathsAfter.first.routeWeight, equals(weightBefore));
|
||||
});
|
||||
|
||||
test('flood stats still recorded independently', () async {
|
||||
final pubKey = _hex('fa06');
|
||||
await _seed(svc, pubKey, pathBytes: [0x01], hopCount: 1, weight: 1.0);
|
||||
|
||||
// Record a flood success (this updates flood stats).
|
||||
svc.recordPathResult(
|
||||
pubKey,
|
||||
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
|
||||
success: true,
|
||||
tripTimeMs: 5000,
|
||||
);
|
||||
|
||||
// Then attribute the flood success to a path.
|
||||
svc.recordFloodPathAttribution(
|
||||
contactPubKeyHex: pubKey,
|
||||
pathBytes: [0x01],
|
||||
hopCount: 1,
|
||||
tripTimeMs: 5000,
|
||||
successIncrement: 0.5,
|
||||
maxWeight: 5.0,
|
||||
);
|
||||
await _flush();
|
||||
|
||||
// Both flood stats and path attribution should exist.
|
||||
final stats = svc.getFloodStats(pubKey);
|
||||
expect(stats, isNotNull);
|
||||
expect(stats!.successCount, equals(1));
|
||||
|
||||
final paths = svc.getRecentPaths(pubKey);
|
||||
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/models/message.dart';
|
||||
import 'package:meshcore_open/services/message_retry_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Replicates the SHA-256 computation from [MessageRetryService.computeExpectedAckHash]
|
||||
/// so tests can cross-check without calling the real implementation twice.
|
||||
Uint8List _manualAckHash(
|
||||
int timestampSeconds,
|
||||
int attemptMasked, // already masked to 0x03
|
||||
String text,
|
||||
Uint8List senderPubKey,
|
||||
) {
|
||||
final textBytes = utf8.encode(text);
|
||||
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
|
||||
int offset = 0;
|
||||
|
||||
buffer[offset++] = timestampSeconds & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
|
||||
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
|
||||
buffer[offset++] = attemptMasked & 0xFF;
|
||||
|
||||
buffer.setRange(offset, offset + textBytes.length, textBytes);
|
||||
offset += textBytes.length;
|
||||
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
|
||||
|
||||
final hash = sha256.convert(buffer);
|
||||
return Uint8List.fromList(hash.bytes.sublist(0, 4));
|
||||
}
|
||||
|
||||
Uint8List _makeKey(int seed) {
|
||||
final key = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
key[i] = (seed + i) & 0xFF;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
Uint8List _makeRecipientKey() {
|
||||
final key = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
key[i] = (0xAA + i) & 0xFF;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
Contact _makeContact({
|
||||
required Uint8List publicKey,
|
||||
int pathLength = -1,
|
||||
List<int> path = const [],
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
name: 'Test',
|
||||
type: 1,
|
||||
pathLength: pathLength,
|
||||
path: Uint8List.fromList(path),
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
// Fixed inputs reused across groups
|
||||
const int fixedTs = 1700000000;
|
||||
const String fixedText = 'Hello mesh';
|
||||
final Uint8List fixedKey = _makeKey(0x11);
|
||||
final Uint8List recipientKey = _makeRecipientKey();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('computeExpectedAckHash — attempt masking', () {
|
||||
test('attempts 0–3 all produce different hashes', () {
|
||||
final hashes = List.generate(
|
||||
4,
|
||||
(i) => MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
i,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
),
|
||||
);
|
||||
|
||||
// All four must be pairwise distinct
|
||||
for (int i = 0; i < hashes.length; i++) {
|
||||
for (int j = i + 1; j < hashes.length; j++) {
|
||||
expect(
|
||||
hashes[i],
|
||||
isNot(equals(hashes[j])),
|
||||
reason: 'attempt $i and attempt $j should produce different hashes',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('attempt 4 produces same hash as attempt 0 (4 & 0x03 == 0)', () {
|
||||
final hash0 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hash4 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
4,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash4, equals(hash0));
|
||||
});
|
||||
|
||||
test('attempt 5 produces same hash as attempt 1 (5 & 0x03 == 1)', () {
|
||||
final hash1 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
1,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hash5 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
5,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash5, equals(hash1));
|
||||
});
|
||||
|
||||
test('attempt 7 produces same hash as attempt 3 (7 & 0x03 == 3)', () {
|
||||
final hash3 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
3,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hash7 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
7,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash7, equals(hash3));
|
||||
});
|
||||
|
||||
test('same inputs always produce the same hash (deterministic)', () {
|
||||
final first = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
2,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final second = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
2,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(first, equals(second));
|
||||
});
|
||||
|
||||
test('hash is exactly 4 bytes long', () {
|
||||
final hash = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash.length, equals(4));
|
||||
});
|
||||
|
||||
test('hash matches manual SHA-256 computation', () {
|
||||
for (int attempt = 0; attempt < 4; attempt++) {
|
||||
final actual = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
attempt,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final expected = _manualAckHash(fixedTs, attempt, fixedText, fixedKey);
|
||||
expect(
|
||||
actual,
|
||||
equals(expected),
|
||||
reason: 'mismatch at attempt $attempt',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('different timestamps produce different hashes', () {
|
||||
final hashA = MessageRetryService.computeExpectedAckHash(
|
||||
1700000000,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashB = MessageRetryService.computeExpectedAckHash(
|
||||
1700000001,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hashA, isNot(equals(hashB)));
|
||||
});
|
||||
|
||||
test('different texts produce different hashes', () {
|
||||
final hashA = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
'Hello mesh',
|
||||
fixedKey,
|
||||
);
|
||||
final hashB = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
'Hello mesh!',
|
||||
fixedKey,
|
||||
);
|
||||
expect(hashA, isNot(equals(hashB)));
|
||||
});
|
||||
|
||||
test('different sender keys produce different hashes', () {
|
||||
final keyA = _makeKey(0x01);
|
||||
final keyB = _makeKey(0x02);
|
||||
final hashA = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
keyA,
|
||||
);
|
||||
final hashB = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
keyB,
|
||||
);
|
||||
expect(hashA, isNot(equals(hashB)));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('buildSendTextMsgFrame — attempt encoding', () {
|
||||
// Frame layout: [cmd(1)][txtType(1)][attempt(1)][timestamp(4)][pubKeyPrefix(6)][text][null(1)]
|
||||
// So byte index 2 carries the raw attempt & 0xFF.
|
||||
|
||||
test('attempt 0 → byte[2] is 0', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(0));
|
||||
});
|
||||
|
||||
test('attempt 3 → byte[2] is 3', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 3,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(3));
|
||||
});
|
||||
|
||||
test('attempt 4 → byte[2] is 4 (raw value, not clamped to 3)', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 4,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(4));
|
||||
});
|
||||
|
||||
test('attempt 255 → byte[2] is 255', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 255,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(255));
|
||||
});
|
||||
|
||||
test('attempt 256 → byte[2] is 255 (clamped, not wrapped)', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 256,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(255));
|
||||
});
|
||||
|
||||
test('byte[0] is cmdSendTxtMsg (2)', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[0], equals(cmdSendTxtMsg));
|
||||
});
|
||||
|
||||
test('byte[1] is txtTypePlain (0)', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[1], equals(txtTypePlain));
|
||||
});
|
||||
|
||||
test('timestamp bytes[3..6] are little-endian encoded', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
final decoded =
|
||||
frame[3] | (frame[4] << 8) | (frame[5] << 16) | (frame[6] << 24);
|
||||
expect(decoded, equals(fixedTs));
|
||||
});
|
||||
|
||||
test(
|
||||
'pub key prefix (bytes 7..12) matches first 6 bytes of recipient key',
|
||||
() {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame.sublist(7, 13), equals(recipientKey.sublist(0, 6)));
|
||||
},
|
||||
);
|
||||
|
||||
test('frame is null-terminated after text', () {
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
'hi',
|
||||
attempt: 0,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame.last, equals(0));
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group(
|
||||
'ACK hash consistency between computeExpectedAckHash and firmware behavior',
|
||||
() {
|
||||
// The firmware reads the raw attempt byte from the frame, then masks it
|
||||
// with & 3 when computing the ACK hash. Flutter does the same masking
|
||||
// inside computeExpectedAckHash. So the two sides must agree.
|
||||
|
||||
test('attempt 4: flutter hash (4 & 3 = 0) equals hash for attempt 0', () {
|
||||
// Flutter sends raw byte 4 in the frame, but computes hash with 4&3=0.
|
||||
// Firmware reads 4, masks to 0, computes same hash → they match.
|
||||
final hashFor4 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
4,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashFor0 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hashFor4, equals(hashFor0));
|
||||
|
||||
// Also confirm the frame byte is raw 4, not 0
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
fixedText,
|
||||
attempt: 4,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(4), reason: 'frame carries raw attempt byte');
|
||||
});
|
||||
|
||||
test(
|
||||
'attempt 3: flutter hash equals hash computed directly for attempt 3',
|
||||
() {
|
||||
// 3 & 3 == 3, so no wrapping — both sides agree.
|
||||
final hashFor3 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
3,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashFor3Direct = _manualAckHash(
|
||||
fixedTs,
|
||||
3,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hashFor3, equals(hashFor3Direct));
|
||||
|
||||
final frame = buildSendTextMsgFrame(
|
||||
recipientKey,
|
||||
fixedText,
|
||||
attempt: 3,
|
||||
timestampSeconds: fixedTs,
|
||||
);
|
||||
expect(frame[2], equals(3));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'attempt 3 and attempt 4 produce DIFFERENT hashes (3&3=3 vs 4&3=0)',
|
||||
() {
|
||||
final hash3 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
3,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hash4 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
4,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash3, isNot(equals(hash4)));
|
||||
},
|
||||
);
|
||||
|
||||
test('attempt 8 (8&3=0) produces the same hash as attempt 0', () {
|
||||
final hash8 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
8,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hash0 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(hash8, equals(hash0));
|
||||
});
|
||||
|
||||
test(
|
||||
'hash cycle repeats every 4 attempts (modular arithmetic holds)',
|
||||
() {
|
||||
for (int base = 0; base < 4; base++) {
|
||||
final hashBase = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
base,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashPlus4 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
base + 4,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashPlus8 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
base + 8,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(
|
||||
hashPlus4,
|
||||
equals(hashBase),
|
||||
reason: 'attempt ${base + 4} should match attempt $base',
|
||||
);
|
||||
expect(
|
||||
hashPlus8,
|
||||
equals(hashBase),
|
||||
reason: 'attempt ${base + 8} should match attempt $base',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('_AckHashMapping.attemptIndex — indirect verification via public API', () {
|
||||
// _AckHashMapping is private; we validate its purpose indirectly: that
|
||||
// computeExpectedAckHash records the correct per-attempt hash so that the
|
||||
// right hash is matched when an ACK arrives.
|
||||
|
||||
test('each attempt index 0–3 produces a distinct 4-byte hash', () {
|
||||
final hashes = <String, int>{};
|
||||
for (int attempt = 0; attempt < 4; attempt++) {
|
||||
final hash = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
attempt,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hex = hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
expect(
|
||||
hashes.containsKey(hex),
|
||||
isFalse,
|
||||
reason: 'attempt $attempt collides with attempt ${hashes[hex]}',
|
||||
);
|
||||
hashes[hex] = attempt;
|
||||
}
|
||||
expect(hashes.length, equals(4));
|
||||
});
|
||||
|
||||
test(
|
||||
'attempt index wraps: hash for attempt 4 matches stored hash for attempt 0',
|
||||
() {
|
||||
final storedHash = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
0,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
// Simulates firmware reading raw attempt=4 and masking to 0 for hash.
|
||||
final firmwareComputedHash = _manualAckHash(
|
||||
fixedTs,
|
||||
4 & 0x03, // firmware masks here
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
expect(firmwareComputedHash, equals(storedHash));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'attempt index 1 and 5 map to the same slot — ACK from either retry is matched',
|
||||
() {
|
||||
final hashForAttempt1 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
1,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
final hashForAttempt5 = MessageRetryService.computeExpectedAckHash(
|
||||
fixedTs,
|
||||
5,
|
||||
fixedText,
|
||||
fixedKey,
|
||||
);
|
||||
// Both should produce the identical bytes, confirming the service
|
||||
// would record and match the correct attempt index.
|
||||
expect(hashForAttempt5, equals(hashForAttempt1));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('sendMessageWithRetry — auto path fallback', () {
|
||||
test(
|
||||
'preserves the contact path when auto-selection returns null',
|
||||
() async {
|
||||
final retryService = MessageRetryService();
|
||||
Message? addedMessage;
|
||||
final contact = _makeContact(
|
||||
publicKey: recipientKey,
|
||||
pathLength: 2,
|
||||
path: const [0x10, 0x20],
|
||||
);
|
||||
|
||||
retryService.initialize(
|
||||
RetryServiceConfig(
|
||||
sendMessage: (_, _, _, _) {},
|
||||
addMessage: (_, message) => addedMessage = message,
|
||||
updateMessage: (_) {},
|
||||
clearContactPath: (_) {},
|
||||
setContactPath: (_, _, _) {},
|
||||
selectRetryPath: (_, _, _, _) => null,
|
||||
),
|
||||
);
|
||||
|
||||
await retryService.sendMessageWithRetry(
|
||||
contact: contact,
|
||||
text: 'hello',
|
||||
);
|
||||
|
||||
expect(addedMessage, isNotNull);
|
||||
expect(addedMessage!.pathLength, equals(2));
|
||||
expect(
|
||||
addedMessage!.pathBytes,
|
||||
equals(Uint8List.fromList([0x10, 0x20])),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('uses flood when contact is in flood mode', () async {
|
||||
final retryService = MessageRetryService();
|
||||
Message? addedMessage;
|
||||
final contact = _makeContact(
|
||||
publicKey: recipientKey,
|
||||
pathLength: -1,
|
||||
path: const [],
|
||||
);
|
||||
|
||||
retryService.initialize(
|
||||
RetryServiceConfig(
|
||||
sendMessage: (_, _, _, _) {},
|
||||
addMessage: (_, message) => addedMessage = message,
|
||||
updateMessage: (_) {},
|
||||
clearContactPath: (_) {},
|
||||
setContactPath: (_, _, _) {},
|
||||
),
|
||||
);
|
||||
|
||||
await retryService.sendMessageWithRetry(contact: contact, text: 'hello');
|
||||
|
||||
expect(addedMessage, isNotNull);
|
||||
expect(addedMessage!.pathLength, equals(-1));
|
||||
expect(addedMessage!.pathBytes, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user