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
parent
53caec3e14
commit
4962a48e64
@ -0,0 +1,31 @@
|
||||
import '../models/contact.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class PathHelper {
|
||||
static String formatPathHex(List<int> pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
static String resolvePathNames(
|
||||
List<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
) {
|
||||
return pathBytes
|
||||
.map((b) {
|
||||
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
final matches = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKey.first == b &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return hex;
|
||||
if (matches.length == 1) return matches.first.name;
|
||||
return matches.map((c) => c.name).join(' | ');
|
||||
})
|
||||
.join(' \u2192 ');
|
||||
}
|
||||
}
|
||||
@ -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,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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue