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:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 {}
}

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

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

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

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

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

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