mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
84
test/services/bthome_met_history_test.dart
Normal file
84
test/services/bthome_met_history_test.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/bthome_met_history.dart';
|
||||
|
||||
void main() {
|
||||
test('parses a valid BTHome MET history response', () {
|
||||
final parsed = BTHomeMetHistoryParser.parse('1,0,4,11.2,11.8,12.1,12.3');
|
||||
|
||||
expect(parsed.measurement, BTHomeMetMeasurement.temperature);
|
||||
expect(parsed.page, 0);
|
||||
expect(parsed.values, <double>[11.2, 11.8, 12.1, 12.3]);
|
||||
expect(parsed.latest, 12.3);
|
||||
expect(parsed.minimum, 11.2);
|
||||
expect(parsed.maximum, 12.3);
|
||||
});
|
||||
|
||||
test('rejects malformed BTHome MET history counts', () {
|
||||
expect(
|
||||
() => BTHomeMetHistoryParser.parse('2,0,3,41,42'),
|
||||
throwsA(isA<BTHomeMetHistoryFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('detects available BTHome MET measurements from telemetry', () {
|
||||
final contact = Contact(
|
||||
publicKey: Uint8List(32),
|
||||
type: ContactType.sensor,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: 'WX',
|
||||
lastAdvert: 0,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 0,
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 20.1,
|
||||
humidity: 52,
|
||||
extraSensorData: const {
|
||||
'generic_sensor_1': 1,
|
||||
'speed_2': 3.1,
|
||||
'gust_2': 4.8,
|
||||
'rain_2': 12.3,
|
||||
},
|
||||
timestamp: DateTime(2026, 3, 21, 12),
|
||||
),
|
||||
);
|
||||
|
||||
expect(bTHomeMetMeasurementsForContact(contact), <BTHomeMetMeasurement>[
|
||||
BTHomeMetMeasurement.temperature,
|
||||
BTHomeMetMeasurement.humidity,
|
||||
BTHomeMetMeasurement.windSpeed,
|
||||
BTHomeMetMeasurement.gust,
|
||||
BTHomeMetMeasurement.rain,
|
||||
]);
|
||||
expect(supportsBTHomeMetHistory(contact), isTrue);
|
||||
});
|
||||
|
||||
test('does not enable BTHome MET history without channel 1 capability', () {
|
||||
final contact = Contact(
|
||||
publicKey: Uint8List(32),
|
||||
type: ContactType.sensor,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: 'WX',
|
||||
lastAdvert: 0,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 0,
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 20.1,
|
||||
humidity: 52,
|
||||
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
|
||||
timestamp: DateTime(2026, 3, 21, 12),
|
||||
),
|
||||
);
|
||||
|
||||
expect(bTHomeMetMeasurementsForContact(contact), isEmpty);
|
||||
expect(supportsBTHomeMetHistory(contact), isFalse);
|
||||
});
|
||||
}
|
||||
682
test/services/cayenne_lpp_parser_test.dart
Normal file
682
test/services/cayenne_lpp_parser_test.dart
Normal file
@@ -0,0 +1,682 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
void main() {
|
||||
group('CayenneLppParser - GPS Codec Tests', () {
|
||||
test('GPS encoding uses correct 3-byte signed BE format', () {
|
||||
// Test coordinates (Ljubljana, Slovenia)
|
||||
const double lat = 46.0569;
|
||||
const double lon = 14.5058;
|
||||
const double alt = 295.0;
|
||||
|
||||
final encoded = CayenneLppParser.createGpsData(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitude: alt,
|
||||
channel: 0,
|
||||
);
|
||||
|
||||
// Expected format (Standard Cayenne LPP):
|
||||
// [0] = channel (0)
|
||||
// [1] = type (136 = 0x88 = lppGps)
|
||||
// [2-4] = lat as 24-bit signed BE
|
||||
// [5-7] = lon as 24-bit signed BE
|
||||
// [8-10] = alt as 24-bit signed BE
|
||||
expect(encoded.length, equals(11));
|
||||
expect(encoded[0], equals(0)); // channel
|
||||
expect(encoded[1], equals(MeshCoreConstants.lppGps)); // type 0x88
|
||||
|
||||
// Verify big-endian encoding (3 bytes each)
|
||||
final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4];
|
||||
final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7];
|
||||
final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10];
|
||||
|
||||
expect(latEncoded, equals(460569)); // 46.0569 * 10000
|
||||
expect(lonEncoded, equals(145058)); // 14.5058 * 10000
|
||||
expect(altEncoded, equals(29500)); // 295.0 * 100
|
||||
});
|
||||
|
||||
test('GPS decoding uses correct 3-byte BE format and divisor', () {
|
||||
// Create raw GPS telemetry packet (standard Cayenne LPP format)
|
||||
final buffer = <int>[];
|
||||
buffer.add(0); // channel
|
||||
buffer.add(MeshCoreConstants.lppGps); // type 0x88
|
||||
|
||||
// Lat: 46.0569 * 10000 = 460569 = 0x070719
|
||||
buffer.add(0x07); // MSB
|
||||
buffer.add(0x07);
|
||||
buffer.add(0x19); // LSB
|
||||
|
||||
// Lon: 14.5058 * 10000 = 145058 = 0x0236A2
|
||||
buffer.add(0x02);
|
||||
buffer.add(0x36);
|
||||
buffer.add(0xA2);
|
||||
|
||||
// Alt: 295.0 * 100 = 29500 = 0x7 33C
|
||||
buffer.add(0x00);
|
||||
buffer.add(0x73);
|
||||
buffer.add(0x3C);
|
||||
|
||||
final telemetry = CayenneLppParser.parse(Uint8List.fromList(buffer));
|
||||
|
||||
expect(telemetry.gpsLocation, isNotNull);
|
||||
expect(telemetry.gpsLocation!.latitude, closeTo(46.0569, 0.0001));
|
||||
expect(telemetry.gpsLocation!.longitude, closeTo(14.5058, 0.0001));
|
||||
|
||||
// Verify altitude is stored in extra data
|
||||
expect(telemetry.extraSensorData, isNotNull);
|
||||
expect(telemetry.extraSensorData!['altitude_0'], closeTo(295.0, 0.01));
|
||||
});
|
||||
|
||||
test('GPS round-trip encoding/decoding maintains precision', () {
|
||||
// Test various coordinates
|
||||
final testCases = [
|
||||
LatLng(46.0569, 14.5058), // Ljubljana
|
||||
LatLng(37.7749, -122.4194), // San Francisco
|
||||
LatLng(-33.8688, 151.2093), // Sydney
|
||||
LatLng(0.0, 0.0), // Null Island
|
||||
LatLng(89.9999, 179.9999), // Near max
|
||||
LatLng(-89.9999, -179.9999), // Near min
|
||||
];
|
||||
|
||||
for (final coords in testCases) {
|
||||
final encoded = CayenneLppParser.createGpsData(
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
altitude: 100.0,
|
||||
);
|
||||
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
|
||||
expect(
|
||||
decoded.gpsLocation,
|
||||
isNotNull,
|
||||
reason: 'Failed to decode: $coords',
|
||||
);
|
||||
expect(
|
||||
decoded.gpsLocation!.latitude,
|
||||
closeTo(coords.latitude, 0.0001),
|
||||
reason: 'Latitude mismatch for $coords',
|
||||
);
|
||||
expect(
|
||||
decoded.gpsLocation!.longitude,
|
||||
closeTo(coords.longitude, 0.0001),
|
||||
reason: 'Longitude mismatch for $coords',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('GPS decoding validates coordinate ranges', () {
|
||||
// This test documents that coordinates are decoded correctly
|
||||
// and any validation warnings are logged (not enforced)
|
||||
|
||||
// Valid coordinates should decode without issue
|
||||
final validBuffer = <int>[];
|
||||
validBuffer.add(0); // channel
|
||||
validBuffer.add(MeshCoreConstants.lppGps);
|
||||
|
||||
// Lat: 45.0 * 10000 = 450000 = 0x06DDD0 (3 bytes BE)
|
||||
validBuffer.addAll([0x06, 0xDD, 0xD0]);
|
||||
// Lon: 10.0 * 10000 = 100000 = 0x0186A0 (3 bytes BE)
|
||||
validBuffer.addAll([0x01, 0x86, 0xA0]);
|
||||
// Alt: 0 (3 bytes BE)
|
||||
validBuffer.addAll([0x00, 0x00, 0x00]);
|
||||
|
||||
final telemetry = CayenneLppParser.parse(Uint8List.fromList(validBuffer));
|
||||
|
||||
expect(telemetry.gpsLocation, isNotNull);
|
||||
expect(telemetry.gpsLocation!.latitude, closeTo(45.0, 0.0001));
|
||||
expect(telemetry.gpsLocation!.longitude, closeTo(10.0, 0.0001));
|
||||
});
|
||||
|
||||
test('GPS encoding handles negative coordinates correctly', () {
|
||||
const lat = -33.8688;
|
||||
const lon = -151.2093;
|
||||
|
||||
final encoded = CayenneLppParser.createGpsData(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
);
|
||||
|
||||
// Verify 3-byte signed encoding
|
||||
// Lat: -33.8688 * 10000 = -338688
|
||||
// In 24-bit two's complement: -338688 + 0x1000000 = 16438528 = 0xFAD500
|
||||
final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4];
|
||||
// Lon: -151.2093 * 10000 = -1512093
|
||||
// In 24-bit two's complement: -1512093 + 0x1000000 = 15265123 = 0xE8ED63
|
||||
final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7];
|
||||
|
||||
expect(latEncoded, equals(0xFAD500)); // Verify two's complement
|
||||
expect(lonEncoded, equals(0xE8ED63));
|
||||
|
||||
// Verify decoding
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
expect(decoded.gpsLocation!.latitude, closeTo(lat, 0.0001));
|
||||
expect(decoded.gpsLocation!.longitude, closeTo(lon, 0.0001));
|
||||
});
|
||||
|
||||
test('GPS encoding with altitude uses correct precision', () {
|
||||
final encoded = CayenneLppParser.createGpsData(
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
altitude: 1234.56,
|
||||
);
|
||||
|
||||
// Altitude is at bytes 8-10 (3 bytes BE)
|
||||
// Alt: 1234.56 * 100 = 123456 = 0x01E240
|
||||
final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10];
|
||||
|
||||
// Altitude precision is 0.01m (divide by 100)
|
||||
expect(altEncoded, equals(123456)); // 1234.56 * 100
|
||||
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
expect(decoded.extraSensorData!['altitude_0'], closeTo(1234.56, 0.01));
|
||||
});
|
||||
|
||||
test('GPS encoding supports custom channel', () {
|
||||
final encoded = CayenneLppParser.createGpsData(
|
||||
latitude: 1.0,
|
||||
longitude: 2.0,
|
||||
channel: 5,
|
||||
);
|
||||
|
||||
expect(encoded[0], equals(5)); // channel
|
||||
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
expect(decoded.gpsLocation, isNotNull);
|
||||
expect(decoded.extraSensorData!['altitude_5'], isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'OLD BUG: Using 4-byte LE instead of 3-byte BE caused wrong coords',
|
||||
() {
|
||||
// This test documents the bug that was fixed
|
||||
// The old code used 4-byte int32 LE (MeshCore advertisement format)
|
||||
// instead of 3-byte signed BE (standard Cayenne LPP format)
|
||||
|
||||
// Real data from device:
|
||||
// Hex: 06 f7 08 02 38 0e 01 2c 46
|
||||
final realData = <int>[
|
||||
0x00, 0x88, // channel 0, type GPS
|
||||
0x06, 0xf7, 0x08, // lat (3 bytes BE)
|
||||
0x02, 0x38, 0x0e, // lon (3 bytes BE)
|
||||
0x01, 0x2c, 0x46, // alt (3 bytes BE)
|
||||
];
|
||||
|
||||
// CORRECT decoding (3-byte BE):
|
||||
final correctLat =
|
||||
((0x06 << 16) | (0xf7 << 8) | 0x08) / 10000.0; // 45.6456°
|
||||
final correctLon =
|
||||
((0x02 << 16) | (0x38 << 8) | 0x0e) / 10000.0; // 14.5422°
|
||||
|
||||
// OLD BUGGY decoding (4-byte LE - reads wrong bytes!):
|
||||
// Would read: lat=06f70802, lon=38010e2c (completely wrong)
|
||||
final buggyLatRaw = 0x02 | (0x08 << 8) | (0xf7 << 16) | (0x06 << 24);
|
||||
final buggyLat = buggyLatRaw / 10000.0; // 3414.1958° (out of range!)
|
||||
|
||||
// Verify current implementation decodes correctly
|
||||
final telemetry = CayenneLppParser.parse(Uint8List.fromList(realData));
|
||||
expect(telemetry.gpsLocation, isNotNull);
|
||||
expect(telemetry.gpsLocation!.latitude, closeTo(correctLat, 0.0001));
|
||||
expect(telemetry.gpsLocation!.longitude, closeTo(correctLon, 0.0001));
|
||||
|
||||
// Verify it doesn't produce the buggy values
|
||||
expect(telemetry.gpsLocation!.latitude, isNot(equals(buggyLat)));
|
||||
expect(telemetry.gpsLocation!.latitude, lessThan(90.0)); // Valid range
|
||||
expect(telemetry.gpsLocation!.latitude, greaterThan(-90.0));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('CayenneLppParser - Other Sensor Tests', () {
|
||||
test('temperature encoding and decoding', () {
|
||||
const tempCelsius = 23.5;
|
||||
|
||||
final encoded = CayenneLppParser.createTemperatureData(
|
||||
tempCelsius,
|
||||
channel: 1,
|
||||
);
|
||||
|
||||
expect(encoded.length, equals(4)); // channel + type + 2 bytes
|
||||
expect(encoded[0], equals(1)); // channel
|
||||
expect(encoded[1], equals(MeshCoreConstants.lppTemperatureSensor));
|
||||
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
expect(decoded.temperature, closeTo(tempCelsius, 0.1));
|
||||
});
|
||||
|
||||
test('temperature handles negative values', () {
|
||||
const tempCelsius = -15.3;
|
||||
|
||||
final encoded = CayenneLppParser.createTemperatureData(tempCelsius);
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
|
||||
expect(decoded.temperature, closeTo(tempCelsius, 0.1));
|
||||
});
|
||||
|
||||
test('battery voltage encoding and decoding', () {
|
||||
const voltage = 3.85;
|
||||
|
||||
final encoded = CayenneLppParser.createBatteryData(voltage);
|
||||
|
||||
expect(encoded.length, equals(4));
|
||||
expect(encoded[1], equals(MeshCoreConstants.lppAnalogInput));
|
||||
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
|
||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||
expect(decoded.batteryPercentage, greaterThan(0));
|
||||
expect(decoded.batteryPercentage, lessThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('battery percentage calculation', () {
|
||||
// Test battery curve: 3.0V = 0%, 4.2V = 100%
|
||||
final testCases = {
|
||||
2.8: 0.0, // Below minimum
|
||||
3.0: 0.0, // Minimum
|
||||
3.6: 50.0, // Middle
|
||||
4.2: 100.0, // Maximum
|
||||
4.5: 100.0, // Above maximum
|
||||
};
|
||||
|
||||
for (final entry in testCases.entries) {
|
||||
final voltage = entry.key;
|
||||
final expectedPercent = entry.value;
|
||||
|
||||
final encoded = CayenneLppParser.createBatteryData(voltage);
|
||||
final decoded = CayenneLppParser.parse(encoded);
|
||||
|
||||
expect(
|
||||
decoded.batteryPercentage,
|
||||
closeTo(expectedPercent, 1),
|
||||
reason: 'Battery ${voltage}V should be ~$expectedPercent%',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('analog input is recognized as battery', () {
|
||||
final buffer = ByteData(4);
|
||||
buffer.setUint8(0, 0); // channel 0
|
||||
buffer.setUint8(1, MeshCoreConstants.lppAnalogInput);
|
||||
buffer.setInt16(2, 385, Endian.big); // 3.85V * 100
|
||||
|
||||
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
|
||||
|
||||
expect(decoded.batteryPercentage, isNotNull);
|
||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||
});
|
||||
|
||||
test('voltage sensor is recognized as battery', () {
|
||||
final buffer = ByteData(4);
|
||||
buffer.setUint8(0, 0);
|
||||
buffer.setUint8(1, MeshCoreConstants.lppVoltageSensor);
|
||||
buffer.setUint16(2, 385, Endian.big); // 3.85V * 100
|
||||
|
||||
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
|
||||
|
||||
expect(decoded.batteryPercentage, isNotNull);
|
||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||
});
|
||||
|
||||
test('humidity sensor decoding', () {
|
||||
final buffer = ByteData(3);
|
||||
buffer.setUint8(0, 0);
|
||||
buffer.setUint8(1, MeshCoreConstants.lppHumiditySensor);
|
||||
buffer.setUint8(2, 130); // 65% humidity (130 / 2)
|
||||
|
||||
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
|
||||
|
||||
expect(decoded.humidity, equals(65.0));
|
||||
});
|
||||
|
||||
test('barometer sensor decoding', () {
|
||||
final buffer = ByteData(4);
|
||||
buffer.setUint8(0, 0);
|
||||
buffer.setUint8(1, MeshCoreConstants.lppBarometer);
|
||||
buffer.setUint16(2, 10132, Endian.big); // 1013.2 hPa * 10
|
||||
|
||||
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
|
||||
|
||||
expect(decoded.pressure, closeTo(1013.2, 0.1));
|
||||
});
|
||||
|
||||
test('accelerometer sensor stores in extra data', () {
|
||||
final buffer = ByteData(8);
|
||||
buffer.setUint8(0, 0);
|
||||
buffer.setUint8(1, MeshCoreConstants.lppAccelerometer);
|
||||
buffer.setInt16(2, 1000, Endian.big); // x: 1.0 g
|
||||
buffer.setInt16(4, -500, Endian.big); // y: -0.5 g
|
||||
buffer.setInt16(6, 2000, Endian.big); // z: 2.0 g
|
||||
|
||||
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
|
||||
|
||||
expect(decoded.extraSensorData, isNotNull);
|
||||
final accel = decoded.extraSensorData!['accelerometer_0'];
|
||||
expect(accel['x'], closeTo(1.0, 0.001));
|
||||
expect(accel['y'], closeTo(-0.5, 0.001));
|
||||
expect(accel['z'], closeTo(2.0, 0.001));
|
||||
});
|
||||
|
||||
test('extended MeshCore LPP types decode to structured extra data', () {
|
||||
const lppGenericSensor = 100;
|
||||
const lppCurrent = 117;
|
||||
const lppFrequency = 118;
|
||||
const lppAltitude = 121;
|
||||
const lppConcentration = 125;
|
||||
const lppPower = 128;
|
||||
const lppSpeed = 129;
|
||||
const lppDistance = 130;
|
||||
const lppEnergy = 131;
|
||||
const lppDirection = 132;
|
||||
const lppUnixTime = 133;
|
||||
const lppColour = 135;
|
||||
const lppGust = 137;
|
||||
const lppDewPoint = 138;
|
||||
const lppRain = 139;
|
||||
const lppSwitch = 142;
|
||||
|
||||
final payload = Uint8List.fromList([
|
||||
2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300
|
||||
3, lppCurrent, 0x00, 0x0F, // 0.015 A
|
||||
4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz
|
||||
5, lppAltitude, 0x01, 0xF4, // 500 m
|
||||
6, lppConcentration, 0x01, 0x9F, // 415 ppm
|
||||
7, lppPower, 0x00, 0xFA, // 250 W
|
||||
8, lppSpeed, 0x04, 0xD2, // 12.34 m/s
|
||||
9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m
|
||||
10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh
|
||||
11, lppDirection, 0x01, 0x0E, // 270 deg
|
||||
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
|
||||
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
|
||||
15, lppGust, 0x01, 0x72, // 3.70 m/s
|
||||
16, lppDewPoint, 0x00, 0x14, // 2.0 C
|
||||
17, lppRain, 0x00, 0x7B, // 12.3 mm
|
||||
14, lppSwitch, 0x01, // on
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.extraSensorData, isNotNull);
|
||||
expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0));
|
||||
expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001));
|
||||
expect(decoded.extraSensorData!['frequency_4'], equals(1000.0));
|
||||
expect(decoded.extraSensorData!['altitude_5'], equals(500.0));
|
||||
expect(decoded.extraSensorData!['concentration_6'], equals(415.0));
|
||||
expect(decoded.extraSensorData!['power_7'], equals(250.0));
|
||||
expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001));
|
||||
expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001));
|
||||
expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001));
|
||||
expect(decoded.extraSensorData!['direction_11'], equals(270.0));
|
||||
expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456));
|
||||
expect(
|
||||
decoded.extraSensorData!['colour_13'],
|
||||
equals({'r': 255, 'g': 128, 'b': 64}),
|
||||
);
|
||||
expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001));
|
||||
expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001));
|
||||
expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001));
|
||||
expect(decoded.extraSensorData!['switch_14'], equals(1));
|
||||
});
|
||||
|
||||
test('new MeshCore custom telemetry types decode without truncation', () {
|
||||
const lppBinaryMoisture = 157;
|
||||
const lppButtonEvent = 171;
|
||||
const lppDimmer = 172;
|
||||
const lppUv = 173;
|
||||
const lppPm25 = 175;
|
||||
const lppConductivity = 180;
|
||||
const lppDuration = 182;
|
||||
const lppSignedSpeed = 191;
|
||||
const lppSignedPower = 192;
|
||||
const lppSignedCurrent = 193;
|
||||
|
||||
final payload = Uint8List.fromList([
|
||||
2, lppBinaryMoisture, 0x01,
|
||||
3, lppButtonEvent, 0x04,
|
||||
4, lppDimmer, 0xFB, // -5
|
||||
5, lppUv, 0x0A, // 1.0
|
||||
6, lppPm25, 0x00, 0x0C, // 12
|
||||
7, lppConductivity, 0x01, 0xF4, // 500
|
||||
8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s
|
||||
9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s
|
||||
10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W
|
||||
11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A
|
||||
12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.extraSensorData!['binary_moisture_2'], equals(1));
|
||||
expect(decoded.extraSensorData!['button_event_3'], equals(4));
|
||||
expect(decoded.extraSensorData!['dimmer_4'], equals(-5));
|
||||
expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001));
|
||||
expect(decoded.extraSensorData!['pm25_6'], equals(12.0));
|
||||
expect(decoded.extraSensorData!['conductivity_7'], equals(500.0));
|
||||
expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001));
|
||||
expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001));
|
||||
expect(
|
||||
decoded.extraSensorData!['signed_power_10'],
|
||||
closeTo(-10.0, 0.001),
|
||||
);
|
||||
expect(
|
||||
decoded.extraSensorData!['signed_current_11'],
|
||||
closeTo(-1.0, 0.001),
|
||||
);
|
||||
expect(decoded.temperature, closeTo(11.8, 0.1));
|
||||
});
|
||||
|
||||
test(
|
||||
'percentage battery and non-battery voltage channels are preserved separately',
|
||||
() {
|
||||
const lppPercentage = 120;
|
||||
|
||||
final payload = Uint8List.fromList([
|
||||
1, lppPercentage, 66, // battery %
|
||||
2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V
|
||||
2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.batteryPercentage, equals(66.0));
|
||||
expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001));
|
||||
expect(decoded.temperature, closeTo(23.5, 0.1));
|
||||
expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1));
|
||||
},
|
||||
);
|
||||
|
||||
test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () {
|
||||
final payload = Uint8List.fromList([
|
||||
0x01, 0x74, 0x00, 0x00,
|
||||
0x02, 0x78, 0x64,
|
||||
0x02, 0x73, 0x24, 0x32,
|
||||
0x02, 0x65, 0x5A, 0x50,
|
||||
0x02, 0x8A, 0xFF, 0xE4,
|
||||
0x02, 0x74, 0x01, 0xE0,
|
||||
0x02, 0x9D, 0x00,
|
||||
0x02, 0x68, 0x76,
|
||||
0x02, 0x81, 0x01, 0x4A,
|
||||
0x02, 0x89, 0x01, 0xB8,
|
||||
0x02, 0x67, 0x00, 0x2D,
|
||||
0x02, 0xAD, 0x0A,
|
||||
0x02, 0x84, 0x00, 0x56,
|
||||
0x02, 0x8B, 0x00, 0xB3,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.batteryPercentage, isNull);
|
||||
expect(decoded.batteryMilliVolts, isNull);
|
||||
expect(decoded.temperature, closeTo(4.5, 0.1));
|
||||
expect(decoded.humidity, closeTo(59.0, 0.1));
|
||||
expect(decoded.pressure, closeTo(926.6, 0.1));
|
||||
expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001));
|
||||
expect(decoded.extraSensorData!['percentage_2'], equals(100.0));
|
||||
expect(decoded.extraSensorData!['uv_2'], equals(1.0));
|
||||
expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1));
|
||||
expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse);
|
||||
});
|
||||
|
||||
test('unknown sensor type is skipped gracefully', () {
|
||||
final buffer = ByteData(5);
|
||||
buffer.setUint8(0, 0);
|
||||
buffer.setUint8(1, 255); // Unknown type
|
||||
buffer.setUint8(2, 1);
|
||||
buffer.setUint8(3, 2);
|
||||
buffer.setUint8(4, 3);
|
||||
|
||||
// Should not throw, just skip unknown data
|
||||
expect(
|
||||
() => CayenneLppParser.parse(buffer.buffer.asUint8List()),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
|
||||
test('multiple sensors in single packet', () {
|
||||
// Create a combined packet with multiple sensors
|
||||
final buffer = <int>[];
|
||||
|
||||
// GPS (channel 2) - use channel 2 to avoid battery auto-detection
|
||||
buffer.add(2); // channel
|
||||
buffer.add(MeshCoreConstants.lppGps);
|
||||
// Lat: 46.0569 * 10000 = 460569 = 0x070719 (3 bytes BE)
|
||||
buffer.addAll([0x07, 0x07, 0x19]);
|
||||
// Lon: 14.5058 * 10000 = 145058 = 0x0236A2 (3 bytes BE)
|
||||
buffer.addAll([0x02, 0x36, 0xA2]);
|
||||
// Alt: 0 (3 bytes BE)
|
||||
buffer.addAll([0x00, 0x00, 0x00]);
|
||||
|
||||
// Temperature (channel 3)
|
||||
buffer.add(3); // channel
|
||||
buffer.add(MeshCoreConstants.lppTemperatureSensor);
|
||||
buffer.add(0x00); // 23.5°C * 10 = 235 (2 bytes BE)
|
||||
buffer.add(0xEB);
|
||||
|
||||
// Battery (channel 0 - required for battery detection)
|
||||
buffer.add(0); // channel
|
||||
buffer.add(MeshCoreConstants.lppAnalogInput);
|
||||
buffer.add(0x01); // 3.85V * 100 = 385 (2 bytes BE)
|
||||
buffer.add(0x81);
|
||||
|
||||
final decoded = CayenneLppParser.parse(Uint8List.fromList(buffer));
|
||||
|
||||
// All sensors should be decoded
|
||||
expect(decoded.gpsLocation, isNotNull);
|
||||
expect(decoded.gpsLocation!.latitude, closeTo(46.0569, 0.0001));
|
||||
expect(decoded.temperature, closeTo(23.5, 0.1));
|
||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||
});
|
||||
|
||||
test('stops parsing at zero-padded telemetry tail', () {
|
||||
final payload = Uint8List.fromList([
|
||||
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
|
||||
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
|
||||
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
|
||||
expect(decoded.gpsLocation, isNotNull);
|
||||
expect(decoded.gpsLocation!.latitude, 0.0);
|
||||
expect(decoded.gpsLocation!.longitude, 0.0);
|
||||
expect(decoded.extraSensorData?['digital_input_0'], isNull);
|
||||
});
|
||||
|
||||
test('empty data returns empty telemetry', () {
|
||||
final empty = Uint8List(0);
|
||||
final decoded = CayenneLppParser.parse(empty);
|
||||
|
||||
expect(decoded.gpsLocation, isNull);
|
||||
expect(decoded.batteryPercentage, isNull);
|
||||
expect(decoded.temperature, isNull);
|
||||
expect(decoded.extraSensorData, isNull);
|
||||
expect(decoded.timestamp, isNotNull); // Timestamp is always set
|
||||
});
|
||||
|
||||
test('timestamp is set to parse time', () {
|
||||
final before = DateTime.now();
|
||||
final data = CayenneLppParser.createTemperatureData(20.0);
|
||||
final decoded = CayenneLppParser.parse(data);
|
||||
final after = DateTime.now();
|
||||
|
||||
expect(
|
||||
decoded.timestamp.isAfter(before) ||
|
||||
decoded.timestamp.isAtSameMomentAs(before),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
decoded.timestamp.isBefore(after) ||
|
||||
decoded.timestamp.isAtSameMomentAs(after),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('CayenneLppParser - ContactTelemetry Properties', () {
|
||||
test('isRecent returns true for fresh telemetry', () {
|
||||
final data = CayenneLppParser.createTemperatureData(20.0);
|
||||
final telemetry = CayenneLppParser.parse(data);
|
||||
|
||||
expect(telemetry.isRecent, isTrue);
|
||||
});
|
||||
|
||||
test('battery status helpers work correctly', () {
|
||||
final lowBattery = ContactTelemetry(
|
||||
batteryPercentage: 15.0,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
expect(lowBattery.isLowBattery, isTrue);
|
||||
expect(lowBattery.batteryStatus, equals('low'));
|
||||
|
||||
final criticalBattery = ContactTelemetry(
|
||||
batteryPercentage: 5.0,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
expect(criticalBattery.isCriticalBattery, isTrue);
|
||||
|
||||
final goodBattery = ContactTelemetry(
|
||||
batteryPercentage: 75.0,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
expect(goodBattery.isLowBattery, isFalse);
|
||||
expect(goodBattery.batteryStatus, equals('good'));
|
||||
});
|
||||
|
||||
test('copyWith creates modified copy', () {
|
||||
final original = ContactTelemetry(
|
||||
gpsLocation: LatLng(1, 2),
|
||||
batteryPercentage: 50.0,
|
||||
temperature: 20.0,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
final modified = original.copyWith(temperature: 25.0);
|
||||
|
||||
expect(modified.temperature, equals(25.0));
|
||||
expect(modified.batteryPercentage, equals(50.0)); // Unchanged
|
||||
expect(modified.gpsLocation, equals(original.gpsLocation)); // Unchanged
|
||||
});
|
||||
|
||||
test('toString provides readable output', () {
|
||||
final telemetry = ContactTelemetry(
|
||||
gpsLocation: LatLng(46.0569, 14.5058),
|
||||
batteryPercentage: 75.0,
|
||||
temperature: 23.5,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
final str = telemetry.toString();
|
||||
expect(str, contains('ContactTelemetry'));
|
||||
expect(str, contains('46.0569'));
|
||||
expect(str, contains('75'));
|
||||
expect(str, contains('23.5'));
|
||||
});
|
||||
});
|
||||
}
|
||||
23
test/services/compass_support_test.dart
Normal file
23
test/services/compass_support_test.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/compass_support.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
test('reports compass support only on mobile targets', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(CompassSupport.isAvailable, isTrue);
|
||||
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
expect(CompassSupport.isAvailable, isTrue);
|
||||
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
expect(CompassSupport.isAvailable, isFalse);
|
||||
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
|
||||
expect(CompassSupport.isAvailable, isFalse);
|
||||
});
|
||||
}
|
||||
143
test/services/contact_route_resolver_test.dart
Normal file
143
test/services/contact_route_resolver_test.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
|
||||
|
||||
void main() {
|
||||
test('prefers known repeater route when available', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final routedRepeater = _contact(
|
||||
name: 'Routed',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.15,
|
||||
lon: 14.60,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [routedRepeater],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.tokens, [
|
||||
'AA',
|
||||
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to location-only repeaters', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final nearRepeater = _contact(
|
||||
name: 'Near',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.08,
|
||||
lon: 14.55,
|
||||
);
|
||||
final farRepeater = _contact(
|
||||
name: 'Far',
|
||||
type: ContactType.repeater,
|
||||
seed: 11,
|
||||
lat: 46.40,
|
||||
lon: 15.10,
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [nearRepeater, farRepeater],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.selectedContacts.first.displayName, 'Near');
|
||||
});
|
||||
|
||||
test('for known routes prefers anchor closest to the chain end', () {
|
||||
final recipient = _contact(
|
||||
name: 'Target',
|
||||
type: ContactType.chat,
|
||||
seed: 90,
|
||||
lat: 46.20,
|
||||
lon: 14.70,
|
||||
);
|
||||
final chainHop = _contact(
|
||||
name: 'Chain Hop',
|
||||
type: ContactType.repeater,
|
||||
seed: 0xAA,
|
||||
lat: 46.01,
|
||||
lon: 14.51,
|
||||
);
|
||||
final nearAnchor = _contact(
|
||||
name: 'Near Anchor',
|
||||
type: ContactType.repeater,
|
||||
seed: 10,
|
||||
lat: 46.03,
|
||||
lon: 14.53,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
final farAnchor = _contact(
|
||||
name: 'Far Anchor',
|
||||
type: ContactType.repeater,
|
||||
seed: 11,
|
||||
lat: 46.18,
|
||||
lon: 14.68,
|
||||
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
|
||||
outPath: Uint8List.fromList([0xAA]),
|
||||
);
|
||||
|
||||
final plan = ContactRouteResolver.resolveAutomaticRoute(
|
||||
senderLocation: const LatLng(46.00, 14.50),
|
||||
recipient: recipient,
|
||||
availableContacts: [chainHop, nearAnchor, farAnchor],
|
||||
hashSize: 1,
|
||||
);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
|
||||
});
|
||||
}
|
||||
|
||||
Contact _contact({
|
||||
required String name,
|
||||
required ContactType type,
|
||||
required int seed,
|
||||
required double lat,
|
||||
required double lon,
|
||||
int signedPathLen = -1,
|
||||
Uint8List? outPath,
|
||||
}) {
|
||||
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: signedPathLen,
|
||||
outPath: outPath ?? Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).round(),
|
||||
advLon: (lon * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
56
test/services/contact_storage_service_test.dart
Normal file
56
test/services/contact_storage_service_test.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Contact createContact({required Uint8List key, required String name}) {
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (46.0569 * 1e6).round(),
|
||||
advLon: (14.5058 * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('keeps default and custom profile contact storage isolated', () async {
|
||||
final storage = ContactStorageService();
|
||||
final defaultContact = createContact(
|
||||
key: Uint8List.fromList(List<int>.filled(32, 1)),
|
||||
name: 'Default Contact',
|
||||
);
|
||||
final customContact = createContact(
|
||||
key: Uint8List.fromList(List<int>.filled(32, 2)),
|
||||
name: 'Custom Contact',
|
||||
);
|
||||
|
||||
await storage.saveContacts([defaultContact]);
|
||||
await storage.saveContacts([customContact], namespace: 'alpha');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString('stored_contacts'), isNotNull);
|
||||
expect(prefs.getString('profile.alpha.stored_contacts'), isNotNull);
|
||||
|
||||
final defaultContacts = await storage.loadContacts();
|
||||
final customContacts = await storage.loadContacts(namespace: 'alpha');
|
||||
|
||||
expect(defaultContacts.single.advName, defaultContact.advName);
|
||||
expect(customContacts.single.advName, customContact.advName);
|
||||
expect(defaultContacts.single.publicKey, defaultContact.publicKey);
|
||||
expect(customContacts.single.publicKey, customContact.publicKey);
|
||||
});
|
||||
}
|
||||
213
test/services/live_traffic_summary_test.dart
Normal file
213
test/services/live_traffic_summary_test.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
|
||||
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
|
||||
|
||||
BlePacketLog _log({
|
||||
required DateTime timestamp,
|
||||
required PacketDirection direction,
|
||||
required List<int> rawData,
|
||||
int? responseCode,
|
||||
double? snrDb,
|
||||
int? rssiDbm,
|
||||
}) {
|
||||
return BlePacketLog(
|
||||
timestamp: timestamp,
|
||||
rawData: Uint8List.fromList(rawData),
|
||||
direction: direction,
|
||||
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
|
||||
logRxDataInfo: snrDb == null && rssiDbm == null
|
||||
? null
|
||||
: LogRxDataInfo(
|
||||
entropy: 0,
|
||||
isLikelyEncrypted: false,
|
||||
snrDb: snrDb,
|
||||
rssiDbm: rssiDbm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _multiHopRaw({
|
||||
required List<int> hops,
|
||||
int payloadType = 0x01,
|
||||
int hashSize = 2,
|
||||
}) {
|
||||
final hopCount = hops.length ~/ hashSize;
|
||||
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
|
||||
return [
|
||||
0x88,
|
||||
0x00,
|
||||
0x00,
|
||||
payloadType << 2,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
pathDescriptor,
|
||||
...hops,
|
||||
];
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('LiveTrafficSummary', () {
|
||||
test('uses only the rolling 60-second window', () {
|
||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||
final snapshot = LiveTrafficSummary.fromLogs([
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 61)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 20)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||
direction: PacketDirection.tx,
|
||||
rawData: [0x01, 0x02],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 5)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x01, 0x02],
|
||||
responseCode: 0x01,
|
||||
),
|
||||
], now: now);
|
||||
|
||||
expect(snapshot.totalCount, 1);
|
||||
expect(snapshot.rxCount, 1);
|
||||
expect(snapshot.txCount, 0);
|
||||
expect(snapshot.packetsPerMinute, 1);
|
||||
});
|
||||
|
||||
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
|
||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||
final snapshot = LiveTrafficSummary.fromLogs([
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 30)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
|
||||
responseCode: 0x88,
|
||||
snrDb: 12.0,
|
||||
rssiDbm: -84,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 15)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
responseCode: 0x88,
|
||||
snrDb: 6.0,
|
||||
rssiDbm: -90,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 5)),
|
||||
direction: PacketDirection.tx,
|
||||
rawData: [0x03, 0x04],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
], now: now);
|
||||
|
||||
expect(snapshot.latestRssiDbm, -90);
|
||||
expect(snapshot.latestSnrDb, 6.0);
|
||||
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
|
||||
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
|
||||
expect(snapshot.multiHopCount, 2);
|
||||
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
|
||||
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
|
||||
});
|
||||
|
||||
test('normalizes packet rate for windows longer than one minute', () {
|
||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||
final snapshot = LiveTrafficSummary.fromLogs(
|
||||
List.generate(
|
||||
24,
|
||||
(index) => _log(
|
||||
timestamp: now.subtract(Duration(seconds: index * 10)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
),
|
||||
now: now,
|
||||
window: const Duration(minutes: 5),
|
||||
);
|
||||
|
||||
expect(snapshot.totalCount, 24);
|
||||
expect(snapshot.windowDuration, const Duration(minutes: 5));
|
||||
expect(snapshot.packetsPerMinute, 5);
|
||||
});
|
||||
|
||||
test('supports clearing the live view without mutating source logs', () {
|
||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||
final clearAt = now.subtract(const Duration(seconds: 8));
|
||||
final snapshot = LiveTrafficSummary.fromLogs(
|
||||
[
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 4)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
],
|
||||
now: now,
|
||||
clearedAt: clearAt,
|
||||
);
|
||||
|
||||
expect(snapshot.totalCount, 1);
|
||||
expect(snapshot.visibleEntries, hasLength(1));
|
||||
});
|
||||
|
||||
test('counts only RX data packets for device totals', () {
|
||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||
|
||||
expect(
|
||||
LiveTrafficSummary.countRxDataLogs([
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 30)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 20)),
|
||||
direction: PacketDirection.tx,
|
||||
rawData: [0x88, 0x00, 0x00],
|
||||
responseCode: 0x88,
|
||||
),
|
||||
_log(
|
||||
timestamp: now.subtract(const Duration(seconds: 10)),
|
||||
direction: PacketDirection.rx,
|
||||
rawData: [0x05, 0x01, 0x02],
|
||||
responseCode: 0x05,
|
||||
),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes detailed descriptions for known packet types', () {
|
||||
final returnedPath = LiveTrafficEntry.payloadTypeDetails(0x08);
|
||||
final control = LiveTrafficEntry.payloadTypeDetails(0x0B);
|
||||
|
||||
expect(returnedPath.title, 'FLOOD RETURNED_PATH');
|
||||
expect(
|
||||
returnedPath.description,
|
||||
contains('stores that returned path as the peer\'s direct out-path'),
|
||||
);
|
||||
expect(control.label, 'Control packet');
|
||||
expect(control.description, contains('discovery request and response'));
|
||||
});
|
||||
});
|
||||
}
|
||||
55
test/services/location_tracking_service_test.dart
Normal file
55
test/services/location_tracking_service_test.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
|
||||
void main() {
|
||||
final service = LocationTrackingService();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: false,
|
||||
activeProfileId: 'default',
|
||||
);
|
||||
service.fastLocationUpdatesEnabled = false;
|
||||
service.fastLocationMovementThresholdMeters = 10.0;
|
||||
service.fastLocationActiveCadenceSeconds = 60;
|
||||
service.fastLocationChannelIdx = null;
|
||||
});
|
||||
|
||||
test('loads conservative fast location defaults', () async {
|
||||
await service.loadSettings();
|
||||
|
||||
expect(service.fastLocationMovementThresholdMeters, 10.0);
|
||||
expect(service.fastLocationActiveCadenceSeconds, 60);
|
||||
});
|
||||
|
||||
test('persists and restores fast location channel idx', () async {
|
||||
await service.updateFastLocationChannelIdx(3);
|
||||
|
||||
service.fastLocationChannelIdx = null;
|
||||
await service.loadSettings();
|
||||
|
||||
expect(service.fastLocationChannelIdx, 3);
|
||||
});
|
||||
|
||||
test('clears persisted fast location channel idx when unset', () async {
|
||||
await service.updateFastLocationChannelIdx(7);
|
||||
await service.updateFastLocationChannelIdx(null);
|
||||
|
||||
service.fastLocationChannelIdx = 99;
|
||||
await service.loadSettings();
|
||||
|
||||
expect(service.fastLocationChannelIdx, isNull);
|
||||
});
|
||||
|
||||
test('clamps fast location settings to conservative limits', () async {
|
||||
await service.updateFastLocationMovementThreshold(3);
|
||||
await service.updateFastLocationActiveCadenceSeconds(45);
|
||||
|
||||
expect(service.fastLocationMovementThresholdMeters, 10.0);
|
||||
expect(service.fastLocationActiveCadenceSeconds, 60);
|
||||
});
|
||||
}
|
||||
63
test/services/map_marker_service_test.dart
Normal file
63
test/services/map_marker_service_test.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/map_marker_service.dart';
|
||||
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
|
||||
|
||||
void main() {
|
||||
Contact buildContact({
|
||||
required String name,
|
||||
required ContactType type,
|
||||
required int advLat,
|
||||
required int advLon,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List(32),
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(0),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch,
|
||||
advLat: advLat,
|
||||
advLon: advLon,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('contact map markers render shared contact avatars', (tester) async {
|
||||
final service = MapMarkerService();
|
||||
final contact = buildContact(
|
||||
name: 'John Smith',
|
||||
type: ContactType.chat,
|
||||
advLat: (46.0569 * 1e6).round(),
|
||||
advLon: (14.5058 * 1e6).round(),
|
||||
);
|
||||
|
||||
late Widget markerChild;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
final markers = service.generateContactMarkers(
|
||||
contacts: [contact],
|
||||
context: context,
|
||||
);
|
||||
markerChild = markers.single.child;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: Center(child: markerChild))),
|
||||
);
|
||||
|
||||
expect(find.byType(ContactAvatar), findsOneWidget);
|
||||
expect(find.text('JS'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
130
test/services/mesh_map_nodes_service_test.dart
Normal file
130
test/services/mesh_map_nodes_service_test.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await MeshMapNodesService.clearCache();
|
||||
});
|
||||
|
||||
test('fetchNodes persists nodes and cached lookup works offline', () async {
|
||||
final client = MockClient((request) async {
|
||||
expect(
|
||||
request.url.toString(),
|
||||
'https://api.meshcore.nz/api/v1/map/nodes',
|
||||
);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'nodes': [
|
||||
{
|
||||
'type': 1,
|
||||
'name': 'Alpha',
|
||||
'public_key': 'aa11bb22',
|
||||
'latitude': 46.05,
|
||||
'longitude': 14.50,
|
||||
'updated_at': 123456,
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final nodes = await MeshMapNodesService.fetchNodes(client: client);
|
||||
final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(cached, hasLength(1));
|
||||
expect(cached.first.name, 'Alpha');
|
||||
expect(await MeshMapNodesService.hasFreshCache(), isTrue);
|
||||
});
|
||||
|
||||
test('loadCachedNodes ignores stale persisted cache', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'mesh_map_nodes_cache_v1': jsonEncode([
|
||||
{
|
||||
'type': 1,
|
||||
'name': 'Old node',
|
||||
'public_key': 'bb22cc33',
|
||||
'latitude': 46.05,
|
||||
'longitude': 14.50,
|
||||
'updated_at': 123456,
|
||||
},
|
||||
]),
|
||||
'mesh_map_nodes_cache_timestamp_v1': DateTime.now()
|
||||
.subtract(const Duration(hours: 25))
|
||||
.millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
final nodes = await MeshMapNodesService.loadCachedNodes();
|
||||
|
||||
expect(nodes, isEmpty);
|
||||
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
|
||||
});
|
||||
|
||||
test('clearCache removes persisted online node cache', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'nodes': [
|
||||
{
|
||||
'type': 1,
|
||||
'name': 'Alpha',
|
||||
'public_key': 'aa11bb22',
|
||||
'latitude': 46.05,
|
||||
'longitude': 14.50,
|
||||
'updated_at': 123456,
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
await MeshMapNodesService.fetchNodes(client: client);
|
||||
await MeshMapNodesService.clearCache();
|
||||
|
||||
expect(await MeshMapNodesService.loadCachedNodes(), isEmpty);
|
||||
expect(await MeshMapNodesService.cachedAt(), isNull);
|
||||
});
|
||||
|
||||
test('fetchNodes ignores nodes with invalid coordinates', () async {
|
||||
final client = MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'nodes': [
|
||||
{
|
||||
'type': 1,
|
||||
'name': 'Broken',
|
||||
'public_key': 'bad123',
|
||||
'latitude': 3213.0,
|
||||
'longitude': 14.50,
|
||||
'updated_at': 123456,
|
||||
},
|
||||
{
|
||||
'type': 1,
|
||||
'name': 'Alpha',
|
||||
'public_key': 'aa11bb22',
|
||||
'latitude': 46.05,
|
||||
'longitude': 14.50,
|
||||
'updated_at': 123456,
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final nodes = await MeshMapNodesService.fetchNodes(client: client);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(nodes.first.name, 'Alpha');
|
||||
});
|
||||
}
|
||||
192
test/services/message_storage_service_test.dart
Normal file
192
test/services/message_storage_service_test.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||
import 'package:meshcore_sar_app/services/message_storage_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final originalDebugPrint = debugPrint;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
debugPrint = originalDebugPrint;
|
||||
});
|
||||
|
||||
test(
|
||||
'retains path bytes for stored unread message when reception sidecar is missing',
|
||||
() async {
|
||||
final storage = MessageStorageService();
|
||||
final message = Message(
|
||||
id: 'msg-1',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||
pathLen: 2,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'Unread message',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||
isRead: false,
|
||||
);
|
||||
|
||||
await storage.saveMessages(
|
||||
[message],
|
||||
messageReceptionDetails: {
|
||||
message.id: MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
|
||||
pathBytes: const [0xAA, 0xBB, 0xCC],
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('stored_message_reception_details');
|
||||
|
||||
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||
final restoredMessages = await storage.loadMessages();
|
||||
|
||||
expect(restoredDetails.keys, contains(message.id));
|
||||
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||
},
|
||||
);
|
||||
|
||||
test('retains embedded reception details when sidecar is missing', () async {
|
||||
final storage = MessageStorageService();
|
||||
final message = Message(
|
||||
id: 'msg-2',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
|
||||
channelIdx: 2,
|
||||
pathLen: 3,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000100,
|
||||
text: 'Room update',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
|
||||
isRead: false,
|
||||
);
|
||||
|
||||
await storage.saveMessages(
|
||||
[message],
|
||||
messageReceptionDetails: {
|
||||
message.id: MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
|
||||
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||
rssiDbm: -91,
|
||||
snrDb: 7.25,
|
||||
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
|
||||
senderToReceiptMs: 1200,
|
||||
estimatedTransmitMs: 800,
|
||||
postTransmitDelayMs: 400,
|
||||
receivedCopies: 3,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('stored_message_reception_details');
|
||||
|
||||
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||
final restored = restoredDetails[message.id];
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
expect(restored.rssiDbm, -91);
|
||||
expect(restored.snrDb, 7.25);
|
||||
expect(restored.senderToReceiptMs, 1200);
|
||||
expect(restored.estimatedTransmitMs, 800);
|
||||
expect(restored.postTransmitDelayMs, 400);
|
||||
expect(restored.receivedCopies, 3);
|
||||
expect(
|
||||
restored.packetLoggedAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||
);
|
||||
});
|
||||
|
||||
test('persists removed SAR marker IDs', () async {
|
||||
final storage = MessageStorageService();
|
||||
|
||||
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
|
||||
|
||||
final restored = await storage.loadRemovedSarMarkerIds();
|
||||
|
||||
expect(restored, equals({'sar-1', 'sar-2'}));
|
||||
});
|
||||
|
||||
test('keeps default and custom profile message storage isolated', () async {
|
||||
final storage = MessageStorageService();
|
||||
final defaultMessage = Message(
|
||||
id: 'default-msg',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 1, 1, 1, 1, 1]),
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700001000,
|
||||
text: 'Default profile',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700001000500),
|
||||
isRead: true,
|
||||
);
|
||||
final customMessage = Message(
|
||||
id: 'custom-msg',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([2, 2, 2, 2, 2, 2]),
|
||||
channelIdx: 1,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700002000,
|
||||
text: 'Custom profile',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700002000500),
|
||||
isRead: false,
|
||||
);
|
||||
|
||||
await storage.saveMessages([defaultMessage]);
|
||||
await storage.saveMessages([customMessage], namespace: 'alpha');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString('stored_messages'), isNotNull);
|
||||
expect(prefs.getString('profile.alpha.stored_messages'), isNotNull);
|
||||
|
||||
final defaultMessages = await storage.loadMessages();
|
||||
final customMessages = await storage.loadMessages(namespace: 'alpha');
|
||||
|
||||
expect(defaultMessages.single.id, defaultMessage.id);
|
||||
expect(defaultMessages.single.text, defaultMessage.text);
|
||||
expect(customMessages.single.id, customMessage.id);
|
||||
expect(customMessages.single.text, customMessage.text);
|
||||
});
|
||||
|
||||
test('skips unchanged message snapshots', () async {
|
||||
final storage = MessageStorageService();
|
||||
final logs = <String>[];
|
||||
debugPrint = (String? message, {int? wrapWidth}) {
|
||||
if (message != null) {
|
||||
logs.add(message);
|
||||
}
|
||||
};
|
||||
final message = Message(
|
||||
id: 'msg-stable',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([3, 3, 3, 3, 3, 3]),
|
||||
channelIdx: 3,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700003000,
|
||||
text: 'Stable snapshot',
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700003000500),
|
||||
isRead: true,
|
||||
);
|
||||
|
||||
await storage.saveMessages([message]);
|
||||
await storage.saveMessages([message]);
|
||||
|
||||
expect(
|
||||
logs.where((log) => log.contains('Saved 1 messages to storage')),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
}
|
||||
47
test/services/messaging_route_preferences_test.dart
Normal file
47
test/services/messaging_route_preferences_test.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/services/messaging_route_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('route preference defaults are clear-path disabled and fallback enabled', () async {
|
||||
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
|
||||
expect(
|
||||
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('route preferences persist changes', () async {
|
||||
await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
|
||||
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
|
||||
|
||||
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
|
||||
expect(
|
||||
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy auto route rotation preference is removed during cleanup', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'messaging_auto_route_rotation_enabled': true,
|
||||
});
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue);
|
||||
|
||||
await MessagingRoutePreferences.cleanupLegacySettings();
|
||||
|
||||
expect(
|
||||
prefs.containsKey('messaging_auto_route_rotation_enabled'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
115
test/services/nearest_router_selector_test.dart
Normal file
115
test/services/nearest_router_selector_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/nearest_router_selector.dart';
|
||||
|
||||
Contact _buildRepeater({
|
||||
required int seed,
|
||||
required String name,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required int lastAdvert,
|
||||
int outPathLen = -1,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
|
||||
type: ContactType.repeater,
|
||||
flags: 0,
|
||||
outPathLen: outPathLen,
|
||||
outPath: Uint8List(0),
|
||||
advName: name,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: (latitude * 1e6).round(),
|
||||
advLon: (longitude * 1e6).round(),
|
||||
lastMod: lastAdvert,
|
||||
);
|
||||
}
|
||||
|
||||
Position _position(double latitude, double longitude) {
|
||||
return Position(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 1,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 1,
|
||||
heading: 0,
|
||||
headingAccuracy: 1,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('selector chooses nearest eligible repeater', () {
|
||||
final selector = NearestRouterSelector();
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final recipient = _buildRepeater(
|
||||
seed: 90,
|
||||
name: 'Recipient',
|
||||
latitude: 46.05,
|
||||
longitude: 14.50,
|
||||
lastAdvert: now,
|
||||
).copyWith(type: ContactType.chat);
|
||||
|
||||
final selected = selector.select(
|
||||
senderPosition: _position(46.0569, 14.5058),
|
||||
repeaters: [
|
||||
_buildRepeater(
|
||||
seed: 1,
|
||||
name: 'Far',
|
||||
latitude: 46.10,
|
||||
longitude: 14.60,
|
||||
lastAdvert: now,
|
||||
outPathLen: 1,
|
||||
),
|
||||
_buildRepeater(
|
||||
seed: 2,
|
||||
name: 'Near',
|
||||
latitude: 46.0570,
|
||||
longitude: 14.5060,
|
||||
lastAdvert: now - 5,
|
||||
outPathLen: 1,
|
||||
),
|
||||
],
|
||||
recipient: recipient,
|
||||
);
|
||||
|
||||
expect(selected?.advName, 'Near');
|
||||
});
|
||||
|
||||
test('selector skips stale repeaters', () {
|
||||
final selector = NearestRouterSelector();
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final staleAdvert = now - (11 * 60);
|
||||
final recipient = _buildRepeater(
|
||||
seed: 91,
|
||||
name: 'Recipient',
|
||||
latitude: 46.05,
|
||||
longitude: 14.50,
|
||||
lastAdvert: now,
|
||||
).copyWith(type: ContactType.chat);
|
||||
|
||||
final selected = selector.select(
|
||||
senderPosition: _position(46.0569, 14.5058),
|
||||
repeaters: [
|
||||
_buildRepeater(
|
||||
seed: 3,
|
||||
name: 'Stale',
|
||||
latitude: 46.0570,
|
||||
longitude: 14.5060,
|
||||
lastAdvert: staleAdvert,
|
||||
outPathLen: 1,
|
||||
),
|
||||
],
|
||||
recipient: recipient,
|
||||
);
|
||||
|
||||
expect(selected, isNull);
|
||||
});
|
||||
}
|
||||
25
test/services/notification_service_test.dart
Normal file
25
test/services/notification_service_test.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/notification_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('discovery notification preference persists independently', () async {
|
||||
final service = NotificationService();
|
||||
|
||||
await service.setMessageNotificationsEnabled(true);
|
||||
await service.setDiscoveryNotificationsEnabled(false);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
expect(service.messageNotificationsEnabled, isTrue);
|
||||
expect(service.discoveryNotificationsEnabled, isFalse);
|
||||
expect(prefs.getBool('notifications_messages_enabled'), isTrue);
|
||||
expect(prefs.getBool('notifications_discovery_enabled'), isFalse);
|
||||
});
|
||||
}
|
||||
110
test/services/offline_map_caching_provider_test.dart
Normal file
110
test/services/offline_map_caching_provider_test.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
final cache = OfflineTileCacheService.instance;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_');
|
||||
await cache.resetForTesting();
|
||||
cache.setBaseDirForTesting('${tempDir.path}/tiles');
|
||||
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cache.resetForTesting();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('parses query-style tile URLs used by Google layers', () {
|
||||
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
|
||||
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
|
||||
);
|
||||
|
||||
expect(coords, isNotNull);
|
||||
expect(coords!.z, 13);
|
||||
expect(coords.x, 4312);
|
||||
expect(coords.y, 2810);
|
||||
expect(
|
||||
OfflineMapCachingProvider.extractUrlTemplateForTesting(
|
||||
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
|
||||
),
|
||||
'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses Esri tile URLs and preserves y/x order in template', () {
|
||||
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
|
||||
);
|
||||
|
||||
expect(coords, isNotNull);
|
||||
expect(coords!.z, 9);
|
||||
expect(coords.x, 267);
|
||||
expect(coords.y, 173);
|
||||
expect(
|
||||
OfflineMapCachingProvider.extractUrlTemplateForTesting(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
|
||||
),
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns cached raw bytes directly', () async {
|
||||
const url =
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267';
|
||||
final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url);
|
||||
final styleHash = cache.styleHashFromUrl(template);
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
|
||||
await cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: 'Esri',
|
||||
urlTemplate: template,
|
||||
);
|
||||
await cache.putTile(
|
||||
styleHash,
|
||||
9,
|
||||
267,
|
||||
173,
|
||||
bytes,
|
||||
contentType: 'image/jpeg',
|
||||
sourceUrl: url,
|
||||
);
|
||||
|
||||
final provider = OfflineMapCachingProvider(_NullMapCachingProvider());
|
||||
final tile = await provider.getTile(url);
|
||||
|
||||
expect(tile, isNotNull);
|
||||
expect(tile!.bytes, bytes);
|
||||
});
|
||||
}
|
||||
|
||||
class _NullMapCachingProvider implements MapCachingProvider {
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async => null;
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {}
|
||||
}
|
||||
118
test/services/offline_tile_cache_service_test.dart
Normal file
118
test/services/offline_tile_cache_service_test.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
final cache = OfflineTileCacheService.instance;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('offline_tile_cache_test_');
|
||||
await cache.resetForTesting();
|
||||
cache.setBaseDirForTesting('${tempDir.path}/tiles');
|
||||
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await cache.resetForTesting();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('stores original bytes and content type without conversion', () async {
|
||||
const styleHash = 'abcdef123456';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4]);
|
||||
|
||||
await cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: 'Test layer',
|
||||
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
await cache.putTile(
|
||||
styleHash,
|
||||
8,
|
||||
12,
|
||||
34,
|
||||
bytes,
|
||||
contentType: 'image/png; charset=binary',
|
||||
sourceUrl: 'https://example.com/8/12/34.png',
|
||||
);
|
||||
|
||||
final tile = await cache.getTileData(styleHash, 8, 12, 34);
|
||||
expect(tile, isNotNull);
|
||||
expect(tile!.bytes, bytes);
|
||||
expect(tile.contentType, 'image/png');
|
||||
expect(await cache.hasTile(styleHash, 8, 12, 34), isTrue);
|
||||
expect(await cache.getCacheSize(), bytes.length);
|
||||
|
||||
final styles = await cache.listStylesDetailed();
|
||||
expect(styles, hasLength(1));
|
||||
expect(styles.single.tileCount, 1);
|
||||
expect(styles.single.sizeBytes, bytes.length);
|
||||
});
|
||||
|
||||
test('hydrates existing disk cache into sqlite on first access', () async {
|
||||
const styleHash = 'fedcba654321';
|
||||
final base = Directory('${tempDir.path}/tiles/$styleHash/9/13');
|
||||
await base.create(recursive: true);
|
||||
final file = File('${base.path}/42.avif');
|
||||
await file.writeAsBytes([9, 8, 7], flush: true);
|
||||
await File('${tempDir.path}/tiles/$styleHash/meta.json').writeAsString(
|
||||
jsonEncode({
|
||||
'displayName': 'Legacy layer',
|
||||
'urlTemplate': 'https://example.com/{z}/{x}/{y}.avif',
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
|
||||
final tiles = await cache.listTilesForStyle(styleHash);
|
||||
expect(tiles, hasLength(1));
|
||||
expect(tiles.single.z, 9);
|
||||
expect(tiles.single.x, 13);
|
||||
expect(tiles.single.y, 42);
|
||||
|
||||
final tile = await cache.getTileData(styleHash, 9, 13, 42);
|
||||
expect(tile, isNotNull);
|
||||
expect(tile!.bytes, Uint8List.fromList([9, 8, 7]));
|
||||
expect(tile.contentType, 'image/avif');
|
||||
|
||||
final styles = await cache.listStylesDetailed();
|
||||
expect(styles.single.displayName, 'Legacy layer');
|
||||
expect(styles.single.tileCount, 1);
|
||||
expect(styles.single.sizeBytes, 3);
|
||||
});
|
||||
|
||||
test('deletes style metadata and files', () async {
|
||||
const styleHash = 'aabbccddeeff';
|
||||
|
||||
await cache.saveStyleMeta(
|
||||
styleHash,
|
||||
displayName: 'Delete me',
|
||||
urlTemplate: 'https://example.com/{z}/{x}/{y}.jpg',
|
||||
);
|
||||
await cache.putTile(
|
||||
styleHash,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
Uint8List.fromList([5, 6]),
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
|
||||
expect(await cache.getCacheSize(), 2);
|
||||
await cache.deleteStyle(styleHash);
|
||||
expect(await cache.getCacheSize(), 0);
|
||||
expect(await cache.getTileData(styleHash, 1, 2, 3), isNull);
|
||||
expect(await Directory('${tempDir.path}/tiles/$styleHash').exists(), isFalse);
|
||||
});
|
||||
}
|
||||
183
test/services/path_history_service_test.dart
Normal file
183
test/services/path_history_service_test.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||
import 'package:meshcore_sar_app/services/path_history_service.dart';
|
||||
|
||||
Contact _buildContact({
|
||||
required int seed,
|
||||
List<int> pathBytes = const [],
|
||||
int hopCount = 0,
|
||||
int hashSize = 1,
|
||||
}) {
|
||||
final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
||||
final outPath = Uint8List(ContactRouteCodec.maxPathBytes);
|
||||
if (pathBytes.isNotEmpty) {
|
||||
outPath.setRange(0, pathBytes.length, pathBytes);
|
||||
}
|
||||
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded),
|
||||
outPath: encoded == -1 ? Uint8List(0) : outPath,
|
||||
advName: 'Contact $seed',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('manual route override persists across reloads', () async {
|
||||
final contact = _buildContact(seed: 1);
|
||||
final service = PathHistoryService();
|
||||
await service.initialize();
|
||||
await service.setManualSelectionFor(
|
||||
contact.publicKeyHex,
|
||||
PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final reloaded = PathHistoryService();
|
||||
final selection = await reloaded.getManualSelectionForContact(contact);
|
||||
|
||||
expect(selection, isNotNull);
|
||||
expect(selection!.mode, PathSelectionMode.directCurrent);
|
||||
expect(selection.canonicalPath, 'AA,BB');
|
||||
});
|
||||
|
||||
test('selection uses stored manual route before contact route', () async {
|
||||
final contact = _buildContact(
|
||||
seed: 2,
|
||||
pathBytes: const [0x11, 0x22],
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
);
|
||||
final service = PathHistoryService();
|
||||
await service.initialize();
|
||||
await service.setManualSelectionFor(
|
||||
contact.publicKeyHex,
|
||||
PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final selection = await service.getSelectionForContact(contact);
|
||||
|
||||
expect(selection.mode, PathSelectionMode.directCurrent);
|
||||
expect(selection.canonicalPath, 'AA,BB');
|
||||
});
|
||||
|
||||
test('selection falls back to the current contact route', () async {
|
||||
final contact = _buildContact(
|
||||
seed: 3,
|
||||
pathBytes: const [0x10, 0x20, 0x30],
|
||||
hopCount: 1,
|
||||
hashSize: 3,
|
||||
);
|
||||
final service = PathHistoryService();
|
||||
|
||||
final selection = await service.getSelectionForContact(contact);
|
||||
|
||||
expect(selection.mode, PathSelectionMode.directCurrent);
|
||||
expect(selection.canonicalPath, '102030');
|
||||
expect(selection.hashSize, 3);
|
||||
expect(selection.hopCount, 1);
|
||||
});
|
||||
|
||||
test('selection falls back to flood when no route exists', () async {
|
||||
final contact = _buildContact(seed: 4);
|
||||
final service = PathHistoryService();
|
||||
|
||||
final selection = await service.getSelectionForContact(contact);
|
||||
|
||||
expect(selection.mode, PathSelectionMode.flood);
|
||||
expect(selection.pathBytes, isEmpty);
|
||||
});
|
||||
|
||||
test('clearing manual route falls back to the contact route', () async {
|
||||
final contact = _buildContact(
|
||||
seed: 5,
|
||||
pathBytes: const [0x01, 0x02],
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
);
|
||||
final service = PathHistoryService();
|
||||
await service.initialize();
|
||||
await service.setManualSelectionFor(
|
||||
contact.publicKeyHex,
|
||||
PathSelection(
|
||||
mode: PathSelectionMode.directCurrent,
|
||||
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
|
||||
hopCount: 2,
|
||||
hashSize: 1,
|
||||
),
|
||||
);
|
||||
|
||||
await service.clearManualRouteFor(contact.publicKeyHex);
|
||||
final selection = await service.getSelectionForContact(contact);
|
||||
|
||||
expect(selection.mode, PathSelectionMode.directCurrent);
|
||||
expect(selection.canonicalPath, '01,02');
|
||||
});
|
||||
|
||||
test('initialize removes legacy path history storage', () async {
|
||||
final contact = Contact(
|
||||
publicKey: Uint8List.fromList([
|
||||
0xAB,
|
||||
0xC1,
|
||||
0x23,
|
||||
...List<int>.filled(29, 0),
|
||||
]),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: -1,
|
||||
outPath: Uint8List(0),
|
||||
advName: 'Legacy Contact',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
|
||||
'contact_manual_path_overrides_v1': jsonEncode({
|
||||
contact.publicKeyHex: {
|
||||
'pathBytes': [0xAA, 0xBB],
|
||||
'hopCount': 2,
|
||||
'hashSize': 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
final service = PathHistoryService();
|
||||
|
||||
await service.initialize();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.containsKey('contact_path_history_v2'), isFalse);
|
||||
final selection = await service.getManualSelectionForContact(contact);
|
||||
expect(selection, isNotNull);
|
||||
expect(selection!.canonicalPath, 'AA,BB');
|
||||
});
|
||||
}
|
||||
113
test/services/profile_manager_test.dart
Normal file
113
test/services/profile_manager_test.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/config_profile.dart';
|
||||
import 'package:meshcore_sar_app/services/profile_manager.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: ConfigProfile.defaultProfileId,
|
||||
);
|
||||
});
|
||||
|
||||
group('ProfileManager', () {
|
||||
test('shows the built-in default profile on a fresh install', () async {
|
||||
final manager = ProfileManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.activeProfileId, ConfigProfile.defaultProfileId);
|
||||
expect(manager.visibleProfiles, hasLength(1));
|
||||
expect(
|
||||
manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault,
|
||||
isTrue,
|
||||
);
|
||||
expect(ProfileStorageScope.profilesEnabled, isTrue);
|
||||
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
||||
expect(manager.visibleProfiles.single.name, 'Default');
|
||||
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'persists custom profiles and restores scoped active profile state',
|
||||
() async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final profile = ConfigProfile(
|
||||
id: 'profile-alpha',
|
||||
name: 'Alpha',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
|
||||
await manager.upsertProfile(profile);
|
||||
await manager.setActiveProfileId(profile.id);
|
||||
|
||||
final reloaded = ProfileManager();
|
||||
await reloaded.initialize();
|
||||
|
||||
expect(reloaded.profilesEnabled, isTrue);
|
||||
expect(reloaded.activeProfileId, profile.id);
|
||||
expect(reloaded.visibleProfiles.map((item) => item.id), [
|
||||
ConfigProfile.defaultProfileId,
|
||||
profile.id,
|
||||
]);
|
||||
expect(reloaded.getProfile(profile.id)?.name, profile.name);
|
||||
expect(ProfileStorageScope.effectiveNamespace, profile.id);
|
||||
|
||||
await reloaded.setProfilesEnabled(false);
|
||||
|
||||
expect(ProfileStorageScope.profilesEnabled, isFalse);
|
||||
expect(ProfileStorageScope.effectiveNamespace, isNull);
|
||||
expect(reloaded.activeProfileId, profile.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('stores per-device default profiles independently', () async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final profile = ConfigProfile(
|
||||
id: 'profile-alpha',
|
||||
name: 'Alpha',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
|
||||
await manager.upsertProfile(profile);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
profile.id,
|
||||
deviceKey: 'pk:device-a',
|
||||
);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
ConfigProfile.defaultProfileId,
|
||||
deviceKey: 'pk:device-b',
|
||||
);
|
||||
|
||||
final reloaded = ProfileManager();
|
||||
await reloaded.initialize();
|
||||
|
||||
expect(reloaded.profileIdForDevice('pk:device-a'), profile.id);
|
||||
expect(
|
||||
reloaded.profileIdForDevice('pk:device-b'),
|
||||
ConfigProfile.defaultProfileId,
|
||||
);
|
||||
expect(
|
||||
reloaded.profileIdForDevice('pk:device-c'),
|
||||
ConfigProfile.defaultProfileId,
|
||||
);
|
||||
expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue);
|
||||
expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
451
test/services/profile_workspace_coordinator_test.dart
Normal file
451
test/services/profile_workspace_coordinator_test.dart
Normal file
@@ -0,0 +1,451 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'package:meshcore_sar_app/models/config_profile.dart';
|
||||
import 'package:meshcore_sar_app/models/contact_group.dart';
|
||||
import 'package:meshcore_sar_app/models/device_info.dart';
|
||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/map_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
|
||||
import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart';
|
||||
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
|
||||
import 'package:meshcore_sar_app/services/device_config_applicator.dart';
|
||||
import 'package:meshcore_sar_app/services/message_storage_service.dart';
|
||||
import 'package:meshcore_sar_app/services/profile_manager.dart';
|
||||
import 'package:meshcore_sar_app/services/profile_workspace_coordinator.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:meshcore_sar_app/services/map_workspace_snapshot_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: ConfigProfile.defaultProfileId,
|
||||
);
|
||||
});
|
||||
|
||||
group('ProfileWorkspaceCoordinator', () {
|
||||
test(
|
||||
'createProfileFromCurrent starts with empty contacts and messages',
|
||||
() async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final messageStorage = MessageStorageService();
|
||||
final contactStorage = ContactStorageService();
|
||||
|
||||
await messageStorage.saveMessages([_buildMessage('default-msg')]);
|
||||
await contactStorage.saveContacts([
|
||||
_buildContact('Default Contact', 1),
|
||||
]);
|
||||
await contactStorage.saveContactGroups([
|
||||
SavedContactGroup(
|
||||
id: 'group-1',
|
||||
sectionKey: 'manual',
|
||||
label: 'Team',
|
||||
query: '',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
),
|
||||
]);
|
||||
await contactStorage.savePendingAdverts([
|
||||
{'id': 'pending-1'},
|
||||
]);
|
||||
|
||||
final coordinator = _buildCoordinator(profileManager: manager);
|
||||
|
||||
final profile = await coordinator.createProfileFromCurrent(
|
||||
name: 'Empty Profile',
|
||||
);
|
||||
|
||||
expect(
|
||||
await messageStorage.loadMessages(namespace: profile.id),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
await contactStorage.loadContacts(namespace: profile.id),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
await contactStorage.loadContactGroups(namespace: profile.id),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
await contactStorage.loadPendingAdverts(namespace: profile.id),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
expect(await messageStorage.loadMessages(), hasLength(1));
|
||||
expect(await contactStorage.loadContacts(), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'duplicateProfile copies contacts and messages from source profile',
|
||||
() async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final source = ConfigProfile(
|
||||
id: 'profile-source',
|
||||
name: 'Source',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
await manager.upsertProfile(source);
|
||||
|
||||
final messageStorage = MessageStorageService();
|
||||
final contactStorage = ContactStorageService();
|
||||
|
||||
await messageStorage.saveMessages([
|
||||
_buildMessage('source-msg'),
|
||||
], namespace: source.id);
|
||||
await contactStorage.saveContacts([
|
||||
_buildContact('Source Contact', 2),
|
||||
], namespace: source.id);
|
||||
await contactStorage.saveContactGroups([
|
||||
SavedContactGroup(
|
||||
id: 'source-group',
|
||||
sectionKey: 'manual',
|
||||
label: 'Source Team',
|
||||
query: '',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
),
|
||||
], namespace: source.id);
|
||||
await contactStorage.savePendingAdverts([
|
||||
{'id': 'source-pending'},
|
||||
], namespace: source.id);
|
||||
|
||||
final coordinator = _buildCoordinator(profileManager: manager);
|
||||
|
||||
final duplicate = await coordinator.duplicateProfile(source);
|
||||
|
||||
final duplicatedMessages = await messageStorage.loadMessages(
|
||||
namespace: duplicate.id,
|
||||
);
|
||||
final duplicatedContacts = await contactStorage.loadContacts(
|
||||
namespace: duplicate.id,
|
||||
);
|
||||
final duplicatedGroups = await contactStorage.loadContactGroups(
|
||||
namespace: duplicate.id,
|
||||
);
|
||||
final duplicatedPending = await contactStorage.loadPendingAdverts(
|
||||
namespace: duplicate.id,
|
||||
);
|
||||
|
||||
expect(duplicatedMessages.map((item) => item.id), ['source-msg']);
|
||||
expect(duplicatedContacts.map((item) => item.advName), [
|
||||
'Source Contact',
|
||||
]);
|
||||
expect(duplicatedGroups.map((item) => item.id), ['source-group']);
|
||||
expect(duplicatedPending, [
|
||||
{'id': 'source-pending'},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'openProfile disconnects current connection before switching',
|
||||
() async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final target = ConfigProfile(
|
||||
id: 'profile-target',
|
||||
name: 'Target',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
await manager.upsertProfile(target);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider();
|
||||
final coordinator = _buildCoordinator(
|
||||
profileManager: manager,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
await coordinator.openProfile(target.id);
|
||||
|
||||
expect(connectionProvider.disconnectCallCount, 1);
|
||||
expect(manager.activeProfileId, target.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('syncActiveProfileForCurrentDevice uses per-device default', () async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final alpha = ConfigProfile(
|
||||
id: 'profile-alpha',
|
||||
name: 'Alpha',
|
||||
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
await manager.upsertProfile(alpha);
|
||||
await manager.setActiveProfileIdForDevice(
|
||||
alpha.id,
|
||||
deviceKey: 'pk:01020304',
|
||||
);
|
||||
await manager.setActiveProfileId(ConfigProfile.defaultProfileId);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
|
||||
);
|
||||
final voiceProvider = _FakeVoiceProvider();
|
||||
final imageProvider = _FakeImageProvider();
|
||||
final coordinator = _buildCoordinator(
|
||||
profileManager: manager,
|
||||
connectionProvider: connectionProvider,
|
||||
voiceProvider: voiceProvider,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
|
||||
await coordinator.syncActiveProfileForCurrentDevice();
|
||||
|
||||
expect(manager.activeProfileId, alpha.id);
|
||||
expect(connectionProvider.disconnectCallCount, 0);
|
||||
expect(voiceProvider.reloadCallCount, 1);
|
||||
expect(imageProvider.reloadCallCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'syncActiveProfileForCurrentDevice creates a profile for a new device',
|
||||
() async {
|
||||
final manager = ProfileManager();
|
||||
await manager.initialize();
|
||||
await manager.setProfilesEnabled(true);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
deviceInfo: DeviceInfo(
|
||||
deviceId: 'ble-77',
|
||||
deviceName: 'MeshCore-Field Unit',
|
||||
selfName: 'Field Unit',
|
||||
publicKey: Uint8List.fromList([7, 7, 7, 7]),
|
||||
),
|
||||
);
|
||||
final coordinator = _buildCoordinator(
|
||||
profileManager: manager,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
await coordinator.syncActiveProfileForCurrentDevice();
|
||||
|
||||
expect(manager.activeProfileId, isNot(ConfigProfile.defaultProfileId));
|
||||
final profile = manager.getProfile(manager.activeProfileId);
|
||||
expect(profile, isNotNull);
|
||||
expect(profile!.name, 'Device Field Unit');
|
||||
expect(manager.hasProfileForDevice('pk:07070707'), isTrue);
|
||||
expect(
|
||||
manager.profileIdForDevice('pk:07070707'),
|
||||
manager.activeProfileId,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ProfileWorkspaceCoordinator _buildCoordinator({
|
||||
required ProfileManager profileManager,
|
||||
_FakeConnectionProvider? connectionProvider,
|
||||
_FakeVoiceProvider? voiceProvider,
|
||||
_FakeImageProvider? imageProvider,
|
||||
}) {
|
||||
return ProfileWorkspaceCoordinator(
|
||||
profileManager: profileManager,
|
||||
connectionProvider: connectionProvider ?? _FakeConnectionProvider(),
|
||||
contactsProvider: _FakeContactsProvider(),
|
||||
messagesProvider: _FakeMessagesProvider(),
|
||||
sensorsProvider: _FakeSensorsProvider(),
|
||||
mapProvider: _FakeMapProvider(),
|
||||
drawingProvider: _FakeDrawingProvider(),
|
||||
channelsProvider: _FakeChannelsProvider(),
|
||||
voiceProvider: voiceProvider ?? _FakeVoiceProvider(),
|
||||
imageProvider: imageProvider ?? _FakeImageProvider(),
|
||||
appProvider: _FakeAppProvider(),
|
||||
appConfigSnapshotService: _FakeAppConfigSnapshotService(),
|
||||
mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(),
|
||||
deviceConfigApplicator: _FakeDeviceConfigApplicator(),
|
||||
);
|
||||
}
|
||||
|
||||
Message _buildMessage(String id) {
|
||||
return Message(
|
||||
id: id,
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: id,
|
||||
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
|
||||
isRead: true,
|
||||
);
|
||||
}
|
||||
|
||||
Contact _buildContact(String name, int fillByte) {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.filled(32, fillByte)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (46.0569 * 1e6).round(),
|
||||
advLon: (14.5058 * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeAppConfigSnapshotService extends AppConfigSnapshotService {
|
||||
@override
|
||||
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
|
||||
return const AppSettingsProfileSection();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeMapWorkspaceSnapshotService extends MapWorkspaceSnapshotService {
|
||||
@override
|
||||
Future<MapWorkspaceProfileSection> capture({
|
||||
required MapProvider mapProvider,
|
||||
required DrawingProvider drawingProvider,
|
||||
}) async {
|
||||
return const MapWorkspaceProfileSection();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
|
||||
@override
|
||||
ConfigProfileSections capture({
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) {
|
||||
return const ConfigProfileSections();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeConnectionProvider implements ConnectionProvider {
|
||||
_FakeConnectionProvider({DeviceInfo? deviceInfo})
|
||||
: deviceInfo = deviceInfo ?? DeviceInfo();
|
||||
|
||||
int disconnectCallCount = 0;
|
||||
|
||||
@override
|
||||
final DeviceInfo deviceInfo;
|
||||
|
||||
@override
|
||||
ConnectionMode get connectionMode => ConnectionMode.ble;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
disconnectCallCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeContactsProvider implements ContactsProvider {
|
||||
@override
|
||||
Future<void> persistNow() async {}
|
||||
|
||||
@override
|
||||
Future<void> reloadFromStorage({
|
||||
String? namespace,
|
||||
Uint8List? devicePublicKey,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeMessagesProvider implements MessagesProvider {
|
||||
@override
|
||||
Future<void> persistNow() async {}
|
||||
|
||||
@override
|
||||
Future<void> reloadFromStorage({String? namespace}) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeMapProvider implements MapProvider {
|
||||
@override
|
||||
Future<void> reloadProfileScopedState() async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeDrawingProvider implements DrawingProvider {
|
||||
@override
|
||||
Future<void> reloadProfileScopedState() async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeSensorsProvider implements SensorsProvider {
|
||||
@override
|
||||
Future<void> reloadProfileScopedState() async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeChannelsProvider implements ChannelsProvider {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeVoiceProvider implements VoiceProvider {
|
||||
int reloadCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
reloadCallCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeImageProvider implements ip.ImageProvider {
|
||||
int reloadCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
reloadCallCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeAppProvider implements AppProvider {
|
||||
@override
|
||||
Future<void> reloadProfileScopedSettings() async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
60
test/services/relay_candidate_sorter_test.dart
Normal file
60
test/services/relay_candidate_sorter_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/services/relay_candidate_sorter.dart';
|
||||
|
||||
void main() {
|
||||
test('sorts relay candidates by distance from self point', () {
|
||||
final sorter = RelayCandidateSorter();
|
||||
|
||||
final sorted = sorter.sortByDistanceFromSelf([
|
||||
_contact(seed: 1, name: 'Far', lat: 46.08, lon: 14.60),
|
||||
_contact(seed: 2, name: 'Near', lat: 46.0570, lon: 14.5060),
|
||||
_contact(seed: 3, name: 'Middle', lat: 46.06, lon: 14.52),
|
||||
], selfPoint: const LatLng(46.0569, 14.5058));
|
||||
|
||||
expect(sorted.map((contact) => contact.displayName).toList(), [
|
||||
'Near',
|
||||
'Middle',
|
||||
'Far',
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to stable name ordering when self point is unavailable', () {
|
||||
final sorter = RelayCandidateSorter();
|
||||
|
||||
final sorted = sorter.sortByDistanceFromSelf([
|
||||
_contact(seed: 1, name: 'Zulu', lat: 46.08, lon: 14.60),
|
||||
_contact(seed: 2, name: 'Alpha', lat: 46.0570, lon: 14.5060),
|
||||
], selfPoint: null);
|
||||
|
||||
expect(sorted.map((contact) => contact.displayName).toList(), [
|
||||
'Alpha',
|
||||
'Zulu',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Contact _contact({
|
||||
required int seed,
|
||||
required String name,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) {
|
||||
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.repeater,
|
||||
flags: 0,
|
||||
outPathLen: -1,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (lat * 1e6).round(),
|
||||
advLon: (lon * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
280
test/services/traffic_stats_reporting_service_test.dart
Normal file
280
test/services/traffic_stats_reporting_service_test.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
|
||||
|
||||
BlePacketLog _log({
|
||||
required DateTime timestamp,
|
||||
required List<int> rawData,
|
||||
int responseCode = 0x88,
|
||||
}) {
|
||||
return BlePacketLog(
|
||||
timestamp: timestamp,
|
||||
rawData: Uint8List.fromList(rawData),
|
||||
direction: PacketDirection.rx,
|
||||
responseCode: responseCode,
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _routeRaw({
|
||||
required int payloadType,
|
||||
required int pathDescriptor,
|
||||
List<int> pathBytes = const <int>[],
|
||||
}) {
|
||||
return <int>[
|
||||
0x88,
|
||||
0x00,
|
||||
0x00,
|
||||
payloadType << 2,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
pathDescriptor,
|
||||
...pathBytes,
|
||||
];
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads fixed packet type and path mode counters', () async {
|
||||
final capturedPayloads = <Map<String, dynamic>>[];
|
||||
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
|
||||
final service = TrafficStatsReportingService(
|
||||
client: MockClient((request) async {
|
||||
capturedPayloads.add(
|
||||
jsonDecode(request.body) as Map<String, dynamic>,
|
||||
);
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
now: () => now,
|
||||
appVersionProvider: () async => '2026.0402.1+44',
|
||||
);
|
||||
|
||||
await service.initialize(
|
||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||
);
|
||||
expect(service.isEnabled, isTrue);
|
||||
await service.processLogs(<BlePacketLog>[
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x04,
|
||||
pathDescriptor: 0x01,
|
||||
pathBytes: const <int>[0xC0],
|
||||
),
|
||||
),
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 10),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x05,
|
||||
pathDescriptor: 0x41,
|
||||
pathBytes: const <int>[0xC0, 0x10],
|
||||
),
|
||||
),
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 15),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x08,
|
||||
pathDescriptor: 0x81,
|
||||
pathBytes: const <int>[0xC0, 0x10, 0x63],
|
||||
),
|
||||
),
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 20),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x01,
|
||||
pathDescriptor: 0x00,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(capturedPayloads, hasLength(1));
|
||||
|
||||
final payload = capturedPayloads.single;
|
||||
final counts = payload['counts'] as Map<String, dynamic>;
|
||||
expect(payload['deviceKey6'], 'a1b2c3d4e5f6');
|
||||
expect(payload.containsKey('publicKey'), isFalse);
|
||||
expect(payload.containsKey('location'), isFalse);
|
||||
expect(counts['pt_04'], 1);
|
||||
expect(counts['pt_05'], 1);
|
||||
expect(counts['pt_08'], 1);
|
||||
expect(counts['pt_01'], 1);
|
||||
expect(counts['path_mode_1b'], 1);
|
||||
expect(counts['path_mode_2b'], 1);
|
||||
expect(counts['path_mode_3b'], 1);
|
||||
expect(counts['path_mode_none'], 1);
|
||||
expect(service.pendingUploadCount, 0);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test('classifies malformed route descriptors as decode and path failures', () async {
|
||||
final capturedPayloads = <Map<String, dynamic>>[];
|
||||
final service = TrafficStatsReportingService(
|
||||
client: MockClient((request) async {
|
||||
capturedPayloads.add(
|
||||
jsonDecode(request.body) as Map<String, dynamic>,
|
||||
);
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
now: () => DateTime.utc(2026, 4, 3, 10, 6),
|
||||
appVersionProvider: () async => '2026.0402.1+44',
|
||||
);
|
||||
|
||||
await service.initialize(
|
||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||
);
|
||||
await service.setEnabled(true);
|
||||
await service.processLogs(<BlePacketLog>[
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 30),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x04,
|
||||
pathDescriptor: 0x41,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
final counts =
|
||||
(capturedPayloads.single['counts'] as Map<String, dynamic>);
|
||||
expect(counts['decode_fail'], 1);
|
||||
expect(counts['path_mode_unknown'], 1);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test('persists queue and retries deterministically', () async {
|
||||
final requestBodies = <Map<String, dynamic>>[];
|
||||
var shouldFail = true;
|
||||
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
|
||||
|
||||
final failingService = TrafficStatsReportingService(
|
||||
client: MockClient((request) async {
|
||||
requestBodies.add(
|
||||
jsonDecode(request.body) as Map<String, dynamic>,
|
||||
);
|
||||
if (shouldFail) {
|
||||
return http.Response('nope', 503);
|
||||
}
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
now: () => now,
|
||||
appVersionProvider: () async => '2026.0402.1+44',
|
||||
);
|
||||
|
||||
await failingService.initialize(
|
||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||
);
|
||||
await failingService.setEnabled(true);
|
||||
await failingService.processLogs(<BlePacketLog>[
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x04,
|
||||
pathDescriptor: 0x01,
|
||||
pathBytes: const <int>[0xC0],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(failingService.pendingUploadCount, 1);
|
||||
expect(failingService.lastError, 'Upload failed (503)');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
prefs.containsKey('traffic_stats_reporting_queue'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
requestBodies.single['reportId'],
|
||||
'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
|
||||
);
|
||||
failingService.dispose();
|
||||
|
||||
shouldFail = false;
|
||||
now = DateTime.utc(2026, 4, 3, 10, 7);
|
||||
final retryService = TrafficStatsReportingService(
|
||||
client: MockClient((request) async {
|
||||
requestBodies.add(
|
||||
jsonDecode(request.body) as Map<String, dynamic>,
|
||||
);
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
now: () => now,
|
||||
appVersionProvider: () async => '2026.0402.1+44',
|
||||
);
|
||||
await retryService.initialize(
|
||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||
);
|
||||
await retryService.flushPendingUploads();
|
||||
|
||||
expect(retryService.pendingUploadCount, 0);
|
||||
expect(retryService.lastError, isNull);
|
||||
expect(retryService.lastSuccessAt, now);
|
||||
retryService.dispose();
|
||||
});
|
||||
|
||||
test('ignores legacy interval preferences and keeps 5 minute windows', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'traffic_stats_reporting_interval_minutes': 15,
|
||||
});
|
||||
final capturedPayloads = <Map<String, dynamic>>[];
|
||||
final service = TrafficStatsReportingService(
|
||||
client: MockClient((request) async {
|
||||
capturedPayloads.add(
|
||||
jsonDecode(request.body) as Map<String, dynamic>,
|
||||
);
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
now: () => DateTime.utc(2026, 4, 3, 10, 6),
|
||||
appVersionProvider: () async => '2026.0402.1+44',
|
||||
);
|
||||
|
||||
await service.initialize(
|
||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||
);
|
||||
await service.setEnabled(true);
|
||||
await service.processLogs(<BlePacketLog>[
|
||||
_log(
|
||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
|
||||
rawData: _routeRaw(
|
||||
payloadType: 0x04,
|
||||
pathDescriptor: 0x01,
|
||||
pathBytes: const <int>[0xC0],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool('traffic_stats_reporting_enabled'), isTrue);
|
||||
expect(prefs.containsKey('traffic_stats_reporting_interval_minutes'), isFalse);
|
||||
expect(
|
||||
prefs.containsKey('profile.alpha.traffic_stats_reporting_enabled'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
prefs.containsKey(
|
||||
'profile.alpha.traffic_stats_reporting_interval_minutes',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(service.intervalMinutes, 5);
|
||||
expect(capturedPayloads, hasLength(1));
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user