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:
34
test/utils/avatar_label_helper_test.dart
Normal file
34
test/utils/avatar_label_helper_test.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
|
||||
|
||||
void main() {
|
||||
group('AvatarLabelHelper.buildLabel', () {
|
||||
test('returns two initials for multi-word names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
|
||||
});
|
||||
|
||||
test('returns first two characters for single-word names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
|
||||
});
|
||||
|
||||
test('allows three compact characters for hash-prefixed names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
|
||||
});
|
||||
|
||||
test('removes spacing after hash-prefixed names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
|
||||
});
|
||||
|
||||
test('keeps non-hash labels at two characters', () {
|
||||
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
||||
});
|
||||
|
||||
test('strips emoji when building fallback initials', () {
|
||||
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
|
||||
});
|
||||
|
||||
test('strips leading emoji when building fallback initials', () {
|
||||
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
|
||||
});
|
||||
});
|
||||
}
|
||||
364
test/utils/contact_grouping_test.dart
Normal file
364
test/utils/contact_grouping_test.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
|
||||
|
||||
void main() {
|
||||
Contact buildContact({
|
||||
required int seed,
|
||||
required String name,
|
||||
required DateTime lastSeen,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(
|
||||
List<int>.generate(32, (index) => (seed + index) % 255),
|
||||
),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
group('ContactGrouping.inferGroups', () {
|
||||
test(
|
||||
'groups prefixed contacts only when at least four share the prefix',
|
||||
() {
|
||||
final now = DateTime(2026, 3, 10, 12);
|
||||
final groups = ContactGrouping.inferGroups([
|
||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||
buildContact(
|
||||
seed: 2,
|
||||
name: 'SI-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 3,
|
||||
name: 'SI-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 4,
|
||||
name: 'SI-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 5,
|
||||
name: 'OTHER',
|
||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(groups, hasLength(1));
|
||||
expect(groups.first.label, 'SI-');
|
||||
expect(groups.first.contacts.map((contact) => contact.displayName), [
|
||||
'SI-1',
|
||||
'SI-2',
|
||||
'SI-3',
|
||||
'SI-4',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('does not group when only three contacts share a prefix', () {
|
||||
final now = DateTime(2026, 3, 10, 12);
|
||||
final groups = ContactGrouping.inferGroups([
|
||||
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
|
||||
buildContact(
|
||||
seed: 2,
|
||||
name: 'SI-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 3,
|
||||
name: 'SI-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(groups, isEmpty);
|
||||
});
|
||||
|
||||
test('orders groups by latest last seen', () {
|
||||
final now = DateTime(2026, 3, 10, 12);
|
||||
final groups = ContactGrouping.inferGroups([
|
||||
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
|
||||
buildContact(
|
||||
seed: 3,
|
||||
name: 'SI-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 4,
|
||||
name: 'SI-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 5,
|
||||
name: 'SI-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 6,
|
||||
name: 'HR-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 7,
|
||||
name: 'HR-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 8,
|
||||
name: 'HR-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 9,
|
||||
name: 'HR-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.first.label, 'SI-');
|
||||
expect(groups.last.label, 'HR-');
|
||||
});
|
||||
|
||||
test('collapses extra auto-groups into a shared parent prefix', () {
|
||||
final now = DateTime(2026, 3, 10, 12);
|
||||
final groups = ContactGrouping.inferGroups(
|
||||
[
|
||||
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||
buildContact(
|
||||
seed: 2,
|
||||
name: 'AL-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 3,
|
||||
name: 'AL-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 4,
|
||||
name: 'AL-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 5,
|
||||
name: 'BR-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 6,
|
||||
name: 'BR-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 7,
|
||||
name: 'BR-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 8,
|
||||
name: 'BR-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 9,
|
||||
name: 'HU-PE-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 10,
|
||||
name: 'HU-PE-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 11,
|
||||
name: 'HU-GA-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 12,
|
||||
name: 'HU-GA-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 13,
|
||||
name: 'HU-PE-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 12)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 14,
|
||||
name: 'HU-PE-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 13)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 15,
|
||||
name: 'HU-GA-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 14)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 16,
|
||||
name: 'HU-GA-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 15)),
|
||||
),
|
||||
],
|
||||
maxNamedGroups: 2,
|
||||
overflowGroupLabel: 'Others',
|
||||
);
|
||||
|
||||
expect(groups, hasLength(3));
|
||||
expect(groups[0].label, 'AL-');
|
||||
expect(groups[1].label, 'BR-');
|
||||
expect(groups[2].label, 'HU-');
|
||||
expect(groups[2].contacts.map((contact) => contact.displayName), [
|
||||
'HU-PE-1',
|
||||
'HU-PE-2',
|
||||
'HU-GA-1',
|
||||
'HU-GA-2',
|
||||
'HU-PE-3',
|
||||
'HU-PE-4',
|
||||
'HU-GA-3',
|
||||
'HU-GA-4',
|
||||
]);
|
||||
expect(groups[2].matchPrefixes, ['HU-']);
|
||||
});
|
||||
|
||||
test(
|
||||
'does not create an Others bucket when overflow has no shared parent',
|
||||
() {
|
||||
final now = DateTime(2026, 3, 10, 12);
|
||||
final groups = ContactGrouping.inferGroups(
|
||||
[
|
||||
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
|
||||
buildContact(
|
||||
seed: 2,
|
||||
name: 'AL-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 3,
|
||||
name: 'AL-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 2)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 4,
|
||||
name: 'AL-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 3)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 5,
|
||||
name: 'BR-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 4)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 6,
|
||||
name: 'BR-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 5)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 7,
|
||||
name: 'BR-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 6)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 8,
|
||||
name: 'BR-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 7)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 9,
|
||||
name: 'CR-1',
|
||||
lastSeen: now.subtract(const Duration(minutes: 8)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 10,
|
||||
name: 'CR-2',
|
||||
lastSeen: now.subtract(const Duration(minutes: 9)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 11,
|
||||
name: 'CR-3',
|
||||
lastSeen: now.subtract(const Duration(minutes: 10)),
|
||||
),
|
||||
buildContact(
|
||||
seed: 12,
|
||||
name: 'CR-4',
|
||||
lastSeen: now.subtract(const Duration(minutes: 11)),
|
||||
),
|
||||
],
|
||||
maxNamedGroups: 2,
|
||||
overflowGroupLabel: 'Others',
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
|
||||
test('matches the inferred auto-group label for prefixed contacts', () {
|
||||
final contact = buildContact(
|
||||
seed: 99,
|
||||
name: 'SI-1',
|
||||
lastSeen: DateTime(2026, 3, 10, 12),
|
||||
);
|
||||
|
||||
expect(
|
||||
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when a contact has no inferred auto-group label', () {
|
||||
final contact = buildContact(
|
||||
seed: 100,
|
||||
name: 'Lone Contact',
|
||||
lastSeen: DateTime(2026, 3, 10, 12),
|
||||
);
|
||||
|
||||
expect(
|
||||
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('finds a shared inferred label for related repeater names', () {
|
||||
final contacts = [
|
||||
buildContact(
|
||||
seed: 101,
|
||||
name: 'HU-PE',
|
||||
lastSeen: DateTime(2026, 3, 10, 12),
|
||||
),
|
||||
buildContact(
|
||||
seed: 102,
|
||||
name: 'HU-GA',
|
||||
lastSeen: DateTime(2026, 3, 10, 11, 59),
|
||||
),
|
||||
];
|
||||
|
||||
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
|
||||
});
|
||||
|
||||
test('finds a shared parent label for related subgroup prefixes', () {
|
||||
expect(
|
||||
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
|
||||
'HU-',
|
||||
);
|
||||
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
354
test/utils/drawing_message_parser_test.dart
Normal file
354
test/utils/drawing_message_parser_test.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/models/map_drawing.dart';
|
||||
import 'package:meshcore_sar_app/models/map_coordinate_space.dart';
|
||||
import 'package:meshcore_sar_app/utils/drawing_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('DrawingMessageParser - Critical String Formatting Tests', () {
|
||||
test('createDrawingMessage returns raw string, not Object', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'test-123',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(37.7749, -122.4194), LatLng(37.7750, -122.4195)],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
// CRITICAL: Must be a String type
|
||||
expect(message, isA<String>());
|
||||
|
||||
// CRITICAL: Must NOT contain "Instance of" or "Object"
|
||||
expect(message, isNot(contains('Instance of')));
|
||||
expect(message, isNot(contains('Object')));
|
||||
|
||||
// CRITICAL: Must start with D: prefix
|
||||
expect(message, startsWith('D:'));
|
||||
|
||||
// CRITICAL: Must be valid JSON after prefix
|
||||
final jsonPart = message.substring(2);
|
||||
expect(() => jsonPart, returnsNormally);
|
||||
});
|
||||
|
||||
test('createDrawingMessage produces parseable JSON string', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'test-456',
|
||||
color: DrawingColors.palette[2], // green
|
||||
createdAt: DateTime.now(),
|
||||
points: [
|
||||
LatLng(40.7128, -74.0060),
|
||||
LatLng(40.7129, -74.0061),
|
||||
LatLng(40.7130, -74.0062),
|
||||
],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
// Should be parseable back
|
||||
final parsed = DrawingMessageParser.parseDrawingMessage(
|
||||
message,
|
||||
senderName: 'Test Sender',
|
||||
messageId: 'msg-123',
|
||||
);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed, isA<LineDrawing>());
|
||||
expect((parsed as LineDrawing).points.length, equals(3));
|
||||
});
|
||||
|
||||
test(
|
||||
'createDrawingMessage handles rectangle with proper string format',
|
||||
() {
|
||||
final drawing = RectangleDrawing(
|
||||
id: 'rect-789',
|
||||
color: DrawingColors.palette[4], // orange
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(45.5231, -122.6765),
|
||||
bottomRight: LatLng(45.5100, -122.6600),
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
// CRITICAL: Must be pure string
|
||||
expect(message, isA<String>());
|
||||
expect(message, startsWith('D:'));
|
||||
|
||||
// Should contain compact JSON format
|
||||
expect(message, contains('"t":'));
|
||||
expect(message, contains('"c":'));
|
||||
expect(message, contains('"b":'));
|
||||
|
||||
// Must NOT contain any object representations
|
||||
expect(message, isNot(contains('RectangleDrawing')));
|
||||
expect(message, isNot(contains('Instance')));
|
||||
},
|
||||
);
|
||||
|
||||
test('custom-map drawings use D2 format and preserve map metadata', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'custom-1',
|
||||
color: DrawingColors.palette[1],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(120, 200), LatLng(320, 450)],
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: '1234567890abcdef',
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
expect(message, startsWith('D2:'));
|
||||
|
||||
final parsed = DrawingMessageParser.parseDrawingMessage(message);
|
||||
expect(parsed, isA<LineDrawing>());
|
||||
expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap);
|
||||
expect(parsed.mapId, '1234567890ab');
|
||||
final parsedLine = parsed as LineDrawing;
|
||||
expect(parsedLine.points.first.latitude, 120);
|
||||
expect(parsedLine.points.first.longitude, 200);
|
||||
});
|
||||
|
||||
test('JSON encoding produces string with coordinates as numbers', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'coord-test',
|
||||
color: DrawingColors.palette[1], // blue
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(37.77490, -122.41940)],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
// Extract JSON part
|
||||
final jsonStr = message.substring(2);
|
||||
|
||||
// CRITICAL: Coordinates must be numbers, not strings
|
||||
// Format: {"t":0,"c":1,"p":[37.7749,-122.4194]}
|
||||
expect(jsonStr, contains('37.7749'));
|
||||
expect(jsonStr, contains('-122.4194'));
|
||||
|
||||
// Should NOT have coordinates as quoted strings
|
||||
expect(jsonStr, isNot(contains('"37.7749"')));
|
||||
expect(jsonStr, isNot(contains('"-122.4194"')));
|
||||
});
|
||||
|
||||
test('isDrawingMessage correctly identifies valid drawing messages', () {
|
||||
expect(
|
||||
DrawingMessageParser.isDrawingMessage('D:{"t":0,"c":1,"p":[1,2]}'),
|
||||
isTrue,
|
||||
);
|
||||
expect(DrawingMessageParser.isDrawingMessage('S:🧑:37,-122'), isFalse);
|
||||
expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse);
|
||||
expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue);
|
||||
});
|
||||
|
||||
test('parseDrawingMessage returns null for malformed messages', () {
|
||||
expect(DrawingMessageParser.parseDrawingMessage('Not a drawing'), isNull);
|
||||
expect(
|
||||
DrawingMessageParser.parseDrawingMessage('D:invalid json'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trip: create -> parse -> create produces consistent output', () {
|
||||
final original = LineDrawing(
|
||||
id: 'roundtrip-1',
|
||||
color: DrawingColors.palette[3], // yellow
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(51.5074, -0.1278), LatLng(51.5075, -0.1279)],
|
||||
);
|
||||
|
||||
// Create message
|
||||
final message1 = DrawingMessageParser.createDrawingMessage(original);
|
||||
|
||||
// Parse it
|
||||
final parsed = DrawingMessageParser.parseDrawingMessage(
|
||||
message1,
|
||||
senderName: 'TestUser',
|
||||
messageId: 'msg-rt1',
|
||||
);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
|
||||
// Create message again from parsed
|
||||
final message2 = DrawingMessageParser.createDrawingMessage(parsed!);
|
||||
|
||||
// Both messages should have same structure (excluding metadata like IDs)
|
||||
expect(message1.substring(0, 10), equals(message2.substring(0, 10)));
|
||||
});
|
||||
|
||||
test('color indices are preserved as integers in JSON', () {
|
||||
for (int i = 0; i < DrawingColors.palette.length; i++) {
|
||||
final drawing = LineDrawing(
|
||||
id: 'color-$i',
|
||||
color: DrawingColors.palette[i],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(0, 0)],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
final jsonStr = message.substring(2);
|
||||
|
||||
// Color index should be integer in JSON
|
||||
expect(jsonStr, contains('"c":$i'));
|
||||
}
|
||||
});
|
||||
|
||||
test('type indices are preserved correctly', () {
|
||||
// Line = type 0
|
||||
final line = LineDrawing(
|
||||
id: 'line-type',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(1, 1), LatLng(2, 2)],
|
||||
);
|
||||
|
||||
final lineMsg = DrawingMessageParser.createDrawingMessage(line);
|
||||
expect(lineMsg, contains('"t":0'));
|
||||
|
||||
// Rectangle = type 1
|
||||
final rect = RectangleDrawing(
|
||||
id: 'rect-type',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(1, 1),
|
||||
bottomRight: LatLng(2, 2),
|
||||
);
|
||||
|
||||
final rectMsg = DrawingMessageParser.createDrawingMessage(rect);
|
||||
expect(rectMsg, contains('"t":1'));
|
||||
});
|
||||
|
||||
test('coordinates are rounded to 5 decimal places', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'precision-test',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(37.774901234567, -122.419401234567)],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
final jsonStr = message.substring(2);
|
||||
|
||||
// Should be rounded to 5 decimals
|
||||
expect(jsonStr, contains('37.7749'));
|
||||
expect(jsonStr, contains('-122.4194'));
|
||||
|
||||
// Should NOT contain extra precision
|
||||
expect(jsonStr, isNot(contains('37.774901234567')));
|
||||
});
|
||||
|
||||
test('empty points array produces valid message', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'empty-line',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
expect(message, isA<String>());
|
||||
expect(message, startsWith('D:'));
|
||||
expect(message, contains('"p":[]'));
|
||||
});
|
||||
|
||||
test('large coordinate values are handled correctly', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'large-coords',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [
|
||||
LatLng(89.99999, 179.99999), // Near max valid coords
|
||||
LatLng(-89.99999, -179.99999), // Near min valid coords
|
||||
],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
expect(message, isA<String>());
|
||||
expect(message, contains('89.99999'));
|
||||
expect(message, contains('179.99999'));
|
||||
expect(message, contains('-89.99999'));
|
||||
expect(message, contains('-179.99999'));
|
||||
});
|
||||
|
||||
test('getDrawingTypeDisplay returns correct type names', () {
|
||||
final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}';
|
||||
final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}';
|
||||
|
||||
expect(
|
||||
DrawingMessageParser.getDrawingTypeDisplay(lineMsg),
|
||||
equals('Line'),
|
||||
);
|
||||
expect(
|
||||
DrawingMessageParser.getDrawingTypeDisplay(rectMsg),
|
||||
equals('Rectangle'),
|
||||
);
|
||||
expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull);
|
||||
});
|
||||
|
||||
test('getColorName returns correct color names', () {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final msg = 'D:{"t":0,"c":$i,"p":[0,0]}';
|
||||
final colorName = DrawingMessageParser.getColorName(msg);
|
||||
expect(colorName, isNotNull);
|
||||
expect(colorName, isA<String>());
|
||||
}
|
||||
|
||||
expect(DrawingMessageParser.getColorName('Invalid'), isNull);
|
||||
});
|
||||
|
||||
test('getDrawingMetadata extracts correct information', () {
|
||||
final lineMsg = 'D:{"t":0,"c":2,"p":[1,2,3,4,5,6]}';
|
||||
final metadata = DrawingMessageParser.getDrawingMetadata(lineMsg);
|
||||
|
||||
expect(metadata, isNotNull);
|
||||
expect(metadata!['type'], equals('Line'));
|
||||
expect(metadata['color'], equals('Green'));
|
||||
expect(metadata['pointCount'], equals(3)); // 6 values = 3 points
|
||||
});
|
||||
|
||||
test('message does not contain sender metadata in JSON', () {
|
||||
final drawing = LineDrawing(
|
||||
id: 'no-sender',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(1, 2)],
|
||||
senderName: 'TestSender', // Should NOT be in network JSON
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
// CRITICAL: Sender name should NOT be in the message
|
||||
expect(message, isNot(contains('sender')));
|
||||
expect(message, isNot(contains('TestSender')));
|
||||
|
||||
// Should only contain compact fields: t, c, p (or b)
|
||||
final jsonStr = message.substring(2);
|
||||
expect(jsonStr, contains('"t":'));
|
||||
expect(jsonStr, contains('"c":'));
|
||||
expect(jsonStr, matches(RegExp(r'"p":|"b":')));
|
||||
});
|
||||
|
||||
test('special characters in metadata do not break format', () {
|
||||
// Even though sender name isn't included in network JSON,
|
||||
// test that it doesn't affect the message creation
|
||||
final drawing = LineDrawing(
|
||||
id: 'special-chars-"quotes"',
|
||||
color: DrawingColors.palette[0],
|
||||
createdAt: DateTime.now(),
|
||||
points: [LatLng(1, 2)],
|
||||
);
|
||||
|
||||
final message = DrawingMessageParser.createDrawingMessage(drawing);
|
||||
|
||||
expect(message, isA<String>());
|
||||
expect(message, startsWith('D:'));
|
||||
// Should still be parseable
|
||||
expect(
|
||||
() => DrawingMessageParser.parseDrawingMessage(message),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
122
test/utils/fast_gps_packet_test.dart
Normal file
122
test/utils/fast_gps_packet_test.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||
|
||||
void main() {
|
||||
group('FastGpsPacket', () {
|
||||
test('encodes and parses a valid 16-byte packet', () {
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: 'aabbccddeeff',
|
||||
latitude: 46.0569,
|
||||
longitude: 14.5058,
|
||||
speedKmh: 37,
|
||||
);
|
||||
|
||||
final encoded = packet.encodeBinary();
|
||||
expect(encoded.length, equals(16));
|
||||
expect(encoded[0], equals(FastGpsPacket.magic));
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(encoded);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.senderKey6, equals('aabbccddeeff'));
|
||||
expect(parsed.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(parsed.longitude, closeTo(14.5058, 0.000001));
|
||||
expect(parsed.speedKmh, equals(37));
|
||||
});
|
||||
|
||||
test('clamps speed to a single unsigned byte', () {
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: '001122334455',
|
||||
latitude: 1.0,
|
||||
longitude: 2.0,
|
||||
speedKmh: 999,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.speedKmh, equals(255));
|
||||
});
|
||||
|
||||
test('supports negative coordinates', () {
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: '001122334455',
|
||||
latitude: -33.8688,
|
||||
longitude: -151.2093,
|
||||
speedKmh: 0,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.latitude, closeTo(-33.8688, 0.000001));
|
||||
expect(parsed.longitude, closeTo(-151.2093, 0.000001));
|
||||
});
|
||||
|
||||
test('rejects malformed payloads', () {
|
||||
// Too short.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
|
||||
isNull,
|
||||
);
|
||||
// Wrong magic at the correct length.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(
|
||||
Uint8List.fromList(List<int>.filled(16, 0)..[0] = 0x48),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
// Legacy 19-byte format is no longer accepted.
|
||||
expect(
|
||||
FastGpsPacket.tryParseBinary(
|
||||
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x47),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid coordinate ranges', () {
|
||||
final payload = Uint8List(16);
|
||||
payload[0] = FastGpsPacket.magic;
|
||||
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
|
||||
final data = ByteData.sublistView(payload);
|
||||
data.setInt32(
|
||||
7,
|
||||
(91.0 * FastGpsPacket.coordinateScale).round(),
|
||||
Endian.little,
|
||||
);
|
||||
data.setInt32(
|
||||
11,
|
||||
(14.5 * FastGpsPacket.coordinateScale).round(),
|
||||
Endian.little,
|
||||
);
|
||||
data.setUint8(15, 1);
|
||||
|
||||
expect(FastGpsPacket.tryParseBinary(payload), isNull);
|
||||
});
|
||||
|
||||
test('preserves at least meter accuracy', () {
|
||||
const latitude = 46.0569123;
|
||||
const longitude = 14.5058123;
|
||||
final packet = FastGpsPacket(
|
||||
senderKey6: 'aabbccddeeff',
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
speedKmh: 5,
|
||||
);
|
||||
|
||||
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||
expect(parsed, isNotNull);
|
||||
|
||||
final latMeters = (parsed!.latitude - latitude).abs() * 111320.0;
|
||||
final lonMeters =
|
||||
(parsed.longitude - longitude).abs() *
|
||||
111320.0 *
|
||||
math.cos(latitude * math.pi / 180.0);
|
||||
|
||||
expect(latMeters, lessThan(1.0));
|
||||
expect(lonMeters, lessThan(1.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
124
test/utils/image_message_parser_test.dart
Normal file
124
test/utils/image_message_parser_test.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('ImageEnvelope', () {
|
||||
test('encodes and parses IE4 with compressed session id', () {
|
||||
final env = ImageEnvelope(
|
||||
sessionId: '0000000a',
|
||||
format: ImageFormat.avif,
|
||||
total: 14,
|
||||
width: 256,
|
||||
height: 171,
|
||||
sizeBytes: 2100,
|
||||
);
|
||||
|
||||
final text = env.encode();
|
||||
expect(text.startsWith('IE4:'), isTrue);
|
||||
expect(text.split(':')[1], equals('a'));
|
||||
|
||||
final parsed = ImageEnvelope.tryParse(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('0000000a'));
|
||||
expect(parsed.format, equals(ImageFormat.avif));
|
||||
expect(parsed.total, equals(14));
|
||||
expect(parsed.width, equals(256));
|
||||
expect(parsed.height, equals(171));
|
||||
expect(parsed.sizeBytes, equals(2100));
|
||||
expect(parsed.version, equals(4));
|
||||
});
|
||||
|
||||
test('rejects IE1 legacy prefix', () {
|
||||
const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1';
|
||||
expect(ImageEnvelope.tryParse(legacy), isNull);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
group('ImageFetchRequest', () {
|
||||
test('encodes and parses IR4 with compressed sid', () {
|
||||
final req = ImageFetchRequest(
|
||||
sessionId: '0000000a',
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
|
||||
final text = req.encode();
|
||||
expect(text.startsWith('IR4:'), isTrue);
|
||||
expect(text.split(':')[1], equals('a'));
|
||||
|
||||
final parsed = ImageFetchRequest.tryParse(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('0000000a'));
|
||||
expect(parsed.want, equals('all'));
|
||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||
expect(parsed.version, equals(4));
|
||||
});
|
||||
|
||||
test('encodes and parses compact missing index ranges', () {
|
||||
final req = ImageFetchRequest(
|
||||
sessionId: '0000000a',
|
||||
want: 'missing',
|
||||
missingIndices: const [0, 1, 2, 5, 6, 8],
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
|
||||
final text = req.encode();
|
||||
expect(text, contains(':m0-2.5-6.8:'));
|
||||
|
||||
final parsed = ImageFetchRequest.tryParse(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.want, equals('missing'));
|
||||
expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8]));
|
||||
});
|
||||
|
||||
test('rejects IR1 legacy prefix', () {
|
||||
const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1';
|
||||
expect(ImageFetchRequest.tryParse(legacy), isNull);
|
||||
});
|
||||
|
||||
test('encodes and parses binary fetch request', () {
|
||||
final req = ImageFetchRequest(
|
||||
sessionId: '01020304',
|
||||
want: 'missing',
|
||||
missingIndices: const [0, 2, 5],
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
|
||||
final payload = req.encodeBinary();
|
||||
expect(ImageFetchRequest.isRequestBinary(payload), isTrue);
|
||||
|
||||
final parsed = ImageFetchRequest.tryParseBinary(payload);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('01020304'));
|
||||
expect(parsed.want, equals('missing'));
|
||||
expect(parsed.missingIndices, equals([0, 2, 5]));
|
||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||
expect(parsed.version, equals(4));
|
||||
});
|
||||
});
|
||||
|
||||
group('ImageFragmentAck', () {
|
||||
test('encodes and parses binary ack', () {
|
||||
final ack = ImageFragmentAck(sessionId: '01020304', index: 9);
|
||||
final payload = ack.encodeBinary();
|
||||
expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue);
|
||||
final parsed = ImageFragmentAck.tryParseBinary(payload);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('01020304'));
|
||||
expect(parsed.index, equals(9));
|
||||
});
|
||||
});
|
||||
|
||||
group('safeImageDataBytesForPath', () {
|
||||
test('caps direct-route fragments to conservative default size', () {
|
||||
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
|
||||
});
|
||||
|
||||
test('shrinks for longer paths but never exceeds conservative default', () {
|
||||
expect(
|
||||
safeImageDataBytesForPath(2),
|
||||
lessThanOrEqualTo(ImagePacket.maxDataBytes),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
25
test/utils/link_quality_test.dart
Normal file
25
test/utils/link_quality_test.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/link_quality.dart';
|
||||
|
||||
void main() {
|
||||
group('linkQualityLabel', () {
|
||||
test('classifies SNR-only values without forcing them to weak', () {
|
||||
expect(linkQualityLabel(null, 12.0), 'Excellent');
|
||||
expect(linkQualityLabel(null, 6.0), 'Good');
|
||||
expect(linkQualityLabel(null, 1.0), 'Fair');
|
||||
expect(linkQualityLabel(null, -6.0), 'Weak');
|
||||
});
|
||||
|
||||
test('classifies RSSI-only values using direct thresholds', () {
|
||||
expect(linkQualityLabel(-58, null), 'Excellent');
|
||||
expect(linkQualityLabel(-68, null), 'Good');
|
||||
expect(linkQualityLabel(-78, null), 'Fair');
|
||||
expect(linkQualityLabel(-92, null), 'Weak');
|
||||
});
|
||||
|
||||
test('averages mixed metrics when both are available', () {
|
||||
expect(linkQualityLabel(-72, 11.0), 'Good');
|
||||
expect(linkQualityLabel(-85, 7.0), 'Fair');
|
||||
});
|
||||
});
|
||||
}
|
||||
31
test/utils/location_formats_test.dart
Normal file
31
test/utils/location_formats_test.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/location_formats.dart';
|
||||
|
||||
void main() {
|
||||
group('formatUtm', () {
|
||||
test('formats northern hemisphere UTM coordinates', () {
|
||||
expect(formatUtm(37.7749, -122.4194), '10S 551131E 4180999N');
|
||||
});
|
||||
|
||||
test('formats southern hemisphere UTM coordinates', () {
|
||||
expect(formatUtm(-33.8688, 151.2093), '56H 334369E 6250948N');
|
||||
});
|
||||
|
||||
test('falls back to decimal outside the UTM latitude range', () {
|
||||
expect(formatUtm(85, 14.5), '85.00000, 14.50000');
|
||||
});
|
||||
});
|
||||
|
||||
group('formatCoordinates', () {
|
||||
test('formats DMS coordinates', () {
|
||||
expect(
|
||||
formatCoordinates(
|
||||
46.0569,
|
||||
14.5058,
|
||||
CoordinateDisplayFormat.dms,
|
||||
),
|
||||
'46°03\'24.84"N 14°30\'20.88"E',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
179
test/utils/log_rx_route_decoder_test.dart
Normal file
179
test/utils/log_rx_route_decoder_test.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart';
|
||||
|
||||
void main() {
|
||||
group('LogRxRouteDecoder.decode', () {
|
||||
test('parses route and sender from LOG_RX_DATA packet', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x05,
|
||||
0x04,
|
||||
0xc2,
|
||||
0xba,
|
||||
0x5f,
|
||||
0xde,
|
||||
0x5c,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.payloadType, 0x01);
|
||||
expect(decoded.pathDescriptor, 0x04);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||
expect(decoded.hashSize, 1);
|
||||
expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']);
|
||||
expect(decoded.originalSenderHashHex, 'c2');
|
||||
});
|
||||
|
||||
test('parses legacy two byte paths as two one-byte hops', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x05,
|
||||
0x02,
|
||||
0xc2,
|
||||
0xba,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.pathDescriptor, 0x02);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba]);
|
||||
expect(decoded.hashSize, 1);
|
||||
expect(decoded.hopCount, 2);
|
||||
expect(decoded.hopHashes, ['c2', 'ba']);
|
||||
});
|
||||
|
||||
test('parses encoded descriptor with 2-byte hashes', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x05,
|
||||
0x42,
|
||||
0xc2,
|
||||
0xba,
|
||||
0x5f,
|
||||
0xde,
|
||||
0x5c,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.pathDescriptor, 0x42);
|
||||
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
|
||||
expect(decoded.hashSize, 2);
|
||||
expect(decoded.hopCount, 2);
|
||||
expect(decoded.hopHashes, ['c2ba', '5fde']);
|
||||
});
|
||||
|
||||
test('uses one byte hashes for legacy packet lengths', () {
|
||||
final packet = Uint8List.fromList([
|
||||
0x88,
|
||||
0x37,
|
||||
0xae,
|
||||
0x09,
|
||||
0x06,
|
||||
0xaa,
|
||||
0xbb,
|
||||
0xcc,
|
||||
0xdd,
|
||||
0xee,
|
||||
0xff,
|
||||
]);
|
||||
|
||||
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.hashSize, 1);
|
||||
expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogRxRouteDecoder.resolveHash', () {
|
||||
test('prefers own node when hash matches device key', () {
|
||||
final resolved = LogRxRouteDecoder.resolveHash(
|
||||
'c201',
|
||||
contacts: const [],
|
||||
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
|
||||
ownName: 'Base',
|
||||
);
|
||||
|
||||
expect(resolved.isOwnNode, isTrue);
|
||||
expect(resolved.label, 'Base (you)');
|
||||
});
|
||||
|
||||
test('resolves unique contact by first public key byte', () {
|
||||
final resolved = LogRxRouteDecoder.resolveHash(
|
||||
'c211',
|
||||
contacts: [
|
||||
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolved.isUniqueMatch, isTrue);
|
||||
expect(resolved.label, 'Alpha');
|
||||
});
|
||||
|
||||
test('marks ambiguous matches without pretending certainty', () {
|
||||
final resolved = LogRxRouteDecoder.resolveHash(
|
||||
'c2',
|
||||
contacts: [
|
||||
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
|
||||
_contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]),
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolved.isUniqueMatch, isFalse);
|
||||
expect(resolved.matchCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogRxRouteDecoder.reverseHopBytes', () {
|
||||
test('reverses path by hop size', () {
|
||||
final reversed = LogRxRouteDecoder.reverseHopBytes([
|
||||
0xc2,
|
||||
0xba,
|
||||
0x5f,
|
||||
0xde,
|
||||
0xaa,
|
||||
0xbb,
|
||||
], hashSize: 2);
|
||||
|
||||
expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Contact _contact({required String name, required List<int> keyPrefix}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList([
|
||||
...keyPrefix,
|
||||
0x11,
|
||||
0x22,
|
||||
0x33,
|
||||
0x44,
|
||||
0x55,
|
||||
0x66,
|
||||
0x77,
|
||||
]),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(0),
|
||||
advName: name,
|
||||
lastAdvert: 0,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 0,
|
||||
);
|
||||
}
|
||||
72
test/utils/media_swarm_protocol_test.dart
Normal file
72
test/utils/media_swarm_protocol_test.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/media_swarm_protocol.dart';
|
||||
|
||||
void main() {
|
||||
group('MediaSwarmProtocol', () {
|
||||
test('encodes and decodes binary missing-fragment requests', () {
|
||||
const request = MediaSwarmRequest(
|
||||
mediaType: 'image',
|
||||
sessionId: 'deadbeef',
|
||||
requesterKey6: 'aabbccddeeff',
|
||||
missingIndices: [9, 2, 9, 0],
|
||||
);
|
||||
|
||||
final decoded = MediaSwarmRequest.tryParseBinary(request.encodeBinary());
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.mediaType, 'image');
|
||||
expect(decoded.sessionId, 'deadbeef');
|
||||
expect(decoded.requesterKey6, 'aabbccddeeff');
|
||||
expect(decoded.missingIndices, [0, 2, 9]);
|
||||
expect(decoded.requestsAll, isFalse);
|
||||
});
|
||||
|
||||
test('encodes and decodes binary availability advertisements', () {
|
||||
const availability = MediaSwarmAvailability(
|
||||
mediaType: 'voice',
|
||||
sessionId: '01020304',
|
||||
requesterKey6: 'aabbccddeeff',
|
||||
responderKey6: '112233445566',
|
||||
availableIndices: [7, 1],
|
||||
);
|
||||
|
||||
final decoded = MediaSwarmAvailability.tryParseBinary(
|
||||
availability.encodeBinary(),
|
||||
);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.mediaType, 'voice');
|
||||
expect(decoded.sessionId, '01020304');
|
||||
expect(decoded.requesterKey6, 'aabbccddeeff');
|
||||
expect(decoded.responderKey6, '112233445566');
|
||||
expect(decoded.availableIndices, [1, 7]);
|
||||
expect(decoded.servesAll, isFalse);
|
||||
});
|
||||
|
||||
test('uses zero-count semantics when no indices are provided', () {
|
||||
const request = MediaSwarmRequest(
|
||||
mediaType: 'voice',
|
||||
sessionId: '01020304',
|
||||
requesterKey6: 'aabbccddeeff',
|
||||
);
|
||||
const availability = MediaSwarmAvailability(
|
||||
mediaType: 'image',
|
||||
sessionId: 'deadbeef',
|
||||
requesterKey6: 'aabbccddeeff',
|
||||
responderKey6: '112233445566',
|
||||
availableIndices: [],
|
||||
);
|
||||
|
||||
expect(
|
||||
MediaSwarmRequest.tryParseBinary(request.encodeBinary())?.requestsAll,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
MediaSwarmAvailability.tryParseBinary(
|
||||
availability.encodeBinary(),
|
||||
)?.servesAll,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
442
test/utils/sar_message_parser_test.dart
Normal file
442
test/utils/sar_message_parser_test.dart
Normal file
@@ -0,0 +1,442 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/models/sar_marker.dart';
|
||||
import 'package:meshcore_sar_app/models/map_coordinate_space.dart';
|
||||
import 'package:meshcore_sar_app/utils/sar_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('SarMessageParser - Critical String Formatting Tests', () {
|
||||
test('createSarMessage returns raw string, not Object', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(37.7749, -122.4194),
|
||||
colorIndex: 2,
|
||||
);
|
||||
|
||||
// CRITICAL: Must be a String type
|
||||
expect(message, isA<String>());
|
||||
|
||||
// CRITICAL: Must NOT contain "Instance of" or "Object"
|
||||
expect(message, isNot(contains('Instance of')));
|
||||
expect(message, isNot(contains('Object')));
|
||||
expect(message, isNot(contains('LatLng')));
|
||||
|
||||
// CRITICAL: Must start with S: prefix
|
||||
expect(message, startsWith('S:'));
|
||||
});
|
||||
|
||||
test('createSarMessage produces correct format with all components', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(37.7749, -122.4194),
|
||||
notes: 'Found alive',
|
||||
colorIndex: 2,
|
||||
);
|
||||
|
||||
// New format: S:<emoji>:<colorIndex>:<lat>,<lon>:<notes>
|
||||
expect(message, startsWith('S:'));
|
||||
expect(message, contains('🧑')); // or 👤
|
||||
expect(message, contains(':2:')); // color index
|
||||
expect(message, contains('37.7749'));
|
||||
expect(message, contains('-122.4194'));
|
||||
expect(message, contains('Found alive'));
|
||||
});
|
||||
|
||||
test('coordinates are converted to strings properly', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.fire,
|
||||
location: LatLng(40.7128, -74.0060),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
// CRITICAL: Coordinates must be in string form, not Object
|
||||
expect(message, contains('40.7128'));
|
||||
expect(
|
||||
message,
|
||||
contains('-74.006'),
|
||||
); // Trailing zero trimmed by toString()
|
||||
|
||||
// Must NOT contain object representation
|
||||
expect(message, isNot(contains('LatLng')));
|
||||
expect(message, isNot(contains('latitude:')));
|
||||
expect(message, isNot(contains('longitude:')));
|
||||
});
|
||||
|
||||
test('colorIndex is converted to string in format', () {
|
||||
for (int i = 0; i <= 7; i++) {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.stagingArea,
|
||||
location: LatLng(0, 0),
|
||||
colorIndex: i,
|
||||
);
|
||||
|
||||
// Color index should be in string
|
||||
expect(message, contains(':$i:'));
|
||||
expect(message, isA<String>());
|
||||
}
|
||||
});
|
||||
|
||||
test('null colorIndex defaults to 0', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(1, 1),
|
||||
colorIndex: null,
|
||||
);
|
||||
|
||||
// Should default to color index 0
|
||||
expect(message, contains(':0:'));
|
||||
});
|
||||
|
||||
test('isSarMessage correctly identifies SAR messages', () {
|
||||
expect(SarMessageParser.isSarMessage('S:🧑:2:37.7,-122.4'), isTrue);
|
||||
expect(SarMessageParser.isSarMessage('S:🔥:0:40.7,-74.0:Fire'), isTrue);
|
||||
expect(SarMessageParser.isSarMessage('D:{"t":0}'), isFalse);
|
||||
expect(SarMessageParser.isSarMessage('Plain text'), isFalse);
|
||||
});
|
||||
|
||||
test('parse correctly extracts all components', () {
|
||||
final message = 'S:🧑:2:37.7749,-122.4194:Found alive';
|
||||
final info = SarMessageParser.parse(message);
|
||||
|
||||
expect(info, isNotNull);
|
||||
expect(info!.type, equals(SarMarkerType.foundPerson));
|
||||
expect(info.location.latitude, equals(37.7749));
|
||||
expect(info.location.longitude, equals(-122.4194));
|
||||
expect(info.colorIndex, equals(2));
|
||||
expect(info.notes, contains('Found alive'));
|
||||
});
|
||||
|
||||
test('parse handles old format without color index', () {
|
||||
// Old format: S:<emoji>:<lat>,<lon>:<notes>
|
||||
final message = 'S:🔥:40.7128,-74.0060:Large fire';
|
||||
final info = SarMessageParser.parse(message);
|
||||
|
||||
expect(info, isNotNull);
|
||||
expect(info!.type, equals(SarMarkerType.fire));
|
||||
expect(info.location.latitude, equals(40.7128));
|
||||
expect(info.location.longitude, equals(-74.0060));
|
||||
expect(info.colorIndex, isNull); // Old format has no color index
|
||||
expect(info.notes, equals('Large fire'));
|
||||
});
|
||||
|
||||
test('custom-map SAR markers use S2 format and preserve map metadata', () {
|
||||
final message = SarMessageParser.createCustomMapSarMessage(
|
||||
emoji: '📦',
|
||||
mapId: 'abcdef1234567890',
|
||||
point: LatLng(250, 400),
|
||||
notes: 'Cache location',
|
||||
colorIndex: 4,
|
||||
);
|
||||
|
||||
expect(message, startsWith('S2:'));
|
||||
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap);
|
||||
expect(parsed.mapId, 'abcdef123456');
|
||||
expect(parsed.location.latitude, 250);
|
||||
expect(parsed.location.longitude, 400);
|
||||
expect(parsed.notes, 'Cache location');
|
||||
});
|
||||
|
||||
test('round-trip: create -> parse -> create preserves format', () {
|
||||
final original = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.stagingArea,
|
||||
location: LatLng(51.5074, -0.1278),
|
||||
notes: 'Command center',
|
||||
colorIndex: 4,
|
||||
);
|
||||
|
||||
// Parse it
|
||||
final parsed = SarMessageParser.parse(original);
|
||||
expect(parsed, isNotNull);
|
||||
|
||||
// Create again
|
||||
final recreated = SarMessageParser.createSarMessage(
|
||||
type: parsed!.type,
|
||||
location: parsed.location,
|
||||
notes: parsed.notes,
|
||||
colorIndex: parsed.colorIndex,
|
||||
);
|
||||
|
||||
// Should be identical
|
||||
expect(recreated, equals(original));
|
||||
});
|
||||
|
||||
test('negative coordinates are handled correctly', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(-33.8688, -151.2093), // Sydney (negative coords)
|
||||
colorIndex: 1,
|
||||
);
|
||||
|
||||
expect(message, contains('-33.8688'));
|
||||
expect(message, contains('-151.2093'));
|
||||
|
||||
// Should be parseable
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.location.latitude, equals(-33.8688));
|
||||
expect(parsed.location.longitude, equals(-151.2093));
|
||||
});
|
||||
|
||||
test('extreme valid coordinates work correctly', () {
|
||||
// Test near max valid latitude/longitude
|
||||
final message1 = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(89.99999, 179.99999),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
expect(message1, contains('89.99999'));
|
||||
expect(message1, contains('179.99999'));
|
||||
|
||||
// Test near min valid latitude/longitude
|
||||
final message2 = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(-89.99999, -179.99999),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
expect(message2, contains('-89.99999'));
|
||||
expect(message2, contains('-179.99999'));
|
||||
});
|
||||
|
||||
test('parse rejects invalid coordinates', () {
|
||||
expect(SarMessageParser.parse('S:🧑:0:91.0,0'), isNull); // lat > 90
|
||||
expect(SarMessageParser.parse('S:🧑:0:-91.0,0'), isNull); // lat < -90
|
||||
expect(SarMessageParser.parse('S:🧑:0:0,181.0'), isNull); // lon > 180
|
||||
expect(SarMessageParser.parse('S:🧑:0:0,-181.0'), isNull); // lon < -180
|
||||
});
|
||||
|
||||
test('parse rejects invalid color indices', () {
|
||||
// Color index should be 0-7
|
||||
final message = 'S:🧑:9:37.7,-122.4'; // Invalid index 9
|
||||
final info = SarMessageParser.parse(message);
|
||||
|
||||
expect(info, isNotNull);
|
||||
expect(info!.colorIndex, isNull); // Should be ignored if invalid
|
||||
});
|
||||
|
||||
test('notes with special characters are preserved', () {
|
||||
final notes = 'Multi-line\nwith: colons, commas';
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(1, 2),
|
||||
notes: notes,
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.notes, contains('Multi-line'));
|
||||
expect(parsed.notes, contains('colons, commas'));
|
||||
});
|
||||
|
||||
test('empty notes produce valid message', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.fire,
|
||||
location: LatLng(1, 2),
|
||||
notes: '',
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
// Should not have trailing colon
|
||||
expect(message, isNot(endsWith(':')));
|
||||
|
||||
// Should be parseable
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
});
|
||||
|
||||
test('null notes produce valid message', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.stagingArea,
|
||||
location: LatLng(1, 2),
|
||||
notes: null,
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
// Should not have notes section
|
||||
expect(message, isNot(endsWith(':')));
|
||||
|
||||
// Should be parseable
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
});
|
||||
|
||||
test('all emoji types produce valid strings', () {
|
||||
final types = [
|
||||
SarMarkerType.foundPerson,
|
||||
SarMarkerType.fire,
|
||||
SarMarkerType.stagingArea,
|
||||
];
|
||||
|
||||
for (final type in types) {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: type,
|
||||
location: LatLng(1, 2),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
expect(message, isA<String>());
|
||||
expect(message, startsWith('S:'));
|
||||
expect(message, contains(type.emoji));
|
||||
|
||||
// Must not contain type name or object representation
|
||||
expect(message, isNot(contains('SarMarkerType')));
|
||||
expect(message, isNot(contains('Instance')));
|
||||
}
|
||||
});
|
||||
|
||||
test('isValidFormat correctly validates messages', () {
|
||||
expect(
|
||||
SarMessageParser.isValidFormat('S:🧑:2:37.7749,-122.4194'),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(SarMessageParser.isValidFormat('S:invalid:format'), isFalse);
|
||||
|
||||
expect(SarMessageParser.isValidFormat('Not SAR message'), isFalse);
|
||||
});
|
||||
|
||||
test('getFormatError provides helpful error messages', () {
|
||||
expect(
|
||||
SarMessageParser.getFormatError('Not SAR'),
|
||||
contains('must start with "S:"'),
|
||||
);
|
||||
|
||||
expect(SarMessageParser.getFormatError('S:'), contains('Invalid format'));
|
||||
|
||||
expect(
|
||||
SarMessageParser.getFormatError('S::37.7,-122.4'),
|
||||
contains('Missing emoji'),
|
||||
);
|
||||
});
|
||||
|
||||
test('extractNotes correctly handles multi-line messages', () {
|
||||
final message = 'S:🧑:0:37.7,-122.4:First line\nSecond line\nThird line';
|
||||
final notes = SarMessageParser.extractNotes(message);
|
||||
|
||||
expect(notes, isNotNull);
|
||||
expect(notes, contains('Second line'));
|
||||
expect(notes, contains('Third line'));
|
||||
});
|
||||
|
||||
test('message format is compact and efficient', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(37.7749, -122.4194),
|
||||
colorIndex: 2,
|
||||
);
|
||||
|
||||
// Should be very compact: S:🧑:2:37.7749,-122.4194
|
||||
expect(message.length, lessThan(50));
|
||||
|
||||
// Should not have extra whitespace
|
||||
expect(message, isNot(contains(' ')));
|
||||
expect(message, isNot(contains('\n')));
|
||||
});
|
||||
|
||||
test('coordinates maintain precision', () {
|
||||
final precise = LatLng(37.774901234, -122.419401234);
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: precise,
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
|
||||
// Should maintain reasonable precision (Dart's toString default)
|
||||
expect(parsed!.location.latitude, closeTo(37.774901234, 0.000001));
|
||||
expect(parsed.location.longitude, closeTo(-122.419401234, 0.000001));
|
||||
});
|
||||
|
||||
test('zero coordinates work correctly', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.stagingArea,
|
||||
location: LatLng(0.0, 0.0),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
expect(message, contains('0.0,0.0'));
|
||||
|
||||
final parsed = SarMessageParser.parse(message);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.location.latitude, equals(0.0));
|
||||
expect(parsed.location.longitude, equals(0.0));
|
||||
});
|
||||
|
||||
test('emoji is preserved as string, not code points', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(1, 2),
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
// Should contain actual emoji character
|
||||
expect(message, contains('🧑'));
|
||||
|
||||
// Should NOT contain unicode code points or escape sequences
|
||||
expect(message, isNot(contains(r'\u')));
|
||||
expect(message, isNot(contains('U+')));
|
||||
});
|
||||
|
||||
test('format is compatible with CLAUDE.md specification', () {
|
||||
// According to CLAUDE.md:
|
||||
// New format: S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.fire,
|
||||
location: LatLng(40.7128, -74.0060),
|
||||
notes: 'Large wildfire spreading rapidly',
|
||||
colorIndex: 0,
|
||||
);
|
||||
|
||||
// Should match: S:🔥:0:40.7128,-74.006:Large wildfire spreading rapidly
|
||||
expect(message, startsWith('S:🔥:0:'));
|
||||
expect(message, contains('40.7128,-74.006')); // Trailing zero trimmed
|
||||
expect(message, endsWith('Large wildfire spreading rapidly'));
|
||||
});
|
||||
|
||||
test('backward compatibility with old format', () {
|
||||
// Old format without color index: S:<emoji>:<lat>,<lon>:<notes>
|
||||
final oldMessage = 'S:🧑:37.7749,-122.4194:Found person';
|
||||
final parsed = SarMessageParser.parse(oldMessage);
|
||||
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.type, equals(SarMarkerType.foundPerson));
|
||||
expect(parsed.colorIndex, isNull); // Old format has no color index
|
||||
expect(parsed.notes, equals('Found person'));
|
||||
});
|
||||
|
||||
test('new format with color index is preferred', () {
|
||||
final message = SarMessageParser.createSarMessage(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(1, 2),
|
||||
colorIndex: 3,
|
||||
);
|
||||
|
||||
// Should use new format with color index
|
||||
expect(message, contains(':3:'));
|
||||
expect(message, contains('🧑'));
|
||||
});
|
||||
|
||||
test('toString produces human-readable output for SarMarkerInfo', () {
|
||||
final info = SarMarkerInfo(
|
||||
type: SarMarkerType.foundPerson,
|
||||
location: LatLng(37.7749, -122.4194),
|
||||
emoji: '🧑',
|
||||
notes: 'Test notes',
|
||||
colorIndex: 2,
|
||||
coordinateSpace: MapCoordinateSpace.geo,
|
||||
);
|
||||
|
||||
final str = info.toString();
|
||||
expect(str, contains('SarMarkerInfo'));
|
||||
expect(str, contains('Found Person')); // Display name, not enum name
|
||||
expect(str, contains('colorIndex: 2'));
|
||||
});
|
||||
});
|
||||
}
|
||||
192
test/utils/voice_message_parser_test.dart
Normal file
192
test/utils/voice_message_parser_test.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('VoiceEnvelope', () {
|
||||
test('encodes and parses valid envelope', () {
|
||||
final env = VoiceEnvelope(
|
||||
sessionId: '0000000a',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 4,
|
||||
durationMs: 3000,
|
||||
);
|
||||
|
||||
final text = env.encodeText();
|
||||
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
|
||||
expect(text.startsWith('VE3:'), isTrue);
|
||||
expect(text.split(':')[1], equals('a'));
|
||||
|
||||
final parsed = VoiceEnvelope.tryParseText(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('0000000a'));
|
||||
expect(parsed.mode, equals(VoicePacketMode.mode1200));
|
||||
expect(parsed.total, equals(4));
|
||||
expect(parsed.durationMs, equals(3000));
|
||||
expect(parsed.version, equals(3));
|
||||
});
|
||||
|
||||
test('rejects invalid envelope payload', () {
|
||||
final text = 'VE3:bad_sid:1:2:1000';
|
||||
expect(VoiceEnvelope.tryParseText(text), isNull);
|
||||
});
|
||||
|
||||
test('rejects legacy v1 envelope prefix', () {
|
||||
const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1';
|
||||
expect(VoiceEnvelope.tryParseText(legacy), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('VoiceFetchRequest', () {
|
||||
test('encodes and parses valid request', () {
|
||||
final req = VoiceFetchRequest(
|
||||
sessionId: '0000000a',
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
final text = req.encodeText();
|
||||
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
|
||||
expect(text.startsWith('VR3:'), isTrue);
|
||||
expect(text.split(':')[1], equals('a'));
|
||||
|
||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('0000000a'));
|
||||
expect(parsed.want, equals('all'));
|
||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||
expect(parsed.version, equals(3));
|
||||
});
|
||||
|
||||
test('rejects invalid request payload', () {
|
||||
expect(
|
||||
VoiceFetchRequest.tryParseText(
|
||||
'VR3:a:chunk:ffeeddccbbaa',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects legacy v1 request prefix', () {
|
||||
const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1';
|
||||
expect(VoiceFetchRequest.tryParseText(legacy), isNull);
|
||||
});
|
||||
|
||||
test('encodes and parses missing-packet request', () {
|
||||
final req = VoiceFetchRequest(
|
||||
sessionId: '0000000a',
|
||||
want: 'missing',
|
||||
missingIndices: const [0, 1, 2, 3, 7],
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
final text = req.encodeText();
|
||||
expect(text, contains(':m0-3.7:'));
|
||||
|
||||
final parsed = VoiceFetchRequest.tryParseText(text);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.want, equals('missing'));
|
||||
expect(parsed.missingIndices, equals([0, 1, 2, 3, 7]));
|
||||
});
|
||||
|
||||
test('encodes and parses binary fetch request', () {
|
||||
final req = VoiceFetchRequest(
|
||||
sessionId: '01020304',
|
||||
want: 'missing',
|
||||
missingIndices: const [1, 4],
|
||||
requesterKey6: 'ffeeddccbbaa',
|
||||
);
|
||||
|
||||
final payload = req.encodeBinary();
|
||||
expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue);
|
||||
|
||||
final parsed = VoiceFetchRequest.tryParseBinary(payload);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('01020304'));
|
||||
expect(parsed.want, equals('missing'));
|
||||
expect(parsed.missingIndices, equals([1, 4]));
|
||||
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
|
||||
expect(parsed.version, equals(3));
|
||||
});
|
||||
});
|
||||
|
||||
group('VoiceFragmentAck', () {
|
||||
test('encodes and parses binary ack', () {
|
||||
final ack = VoiceFragmentAck(sessionId: '01020304', index: 7);
|
||||
final payload = ack.encodeBinary();
|
||||
expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue);
|
||||
final parsed = VoiceFragmentAck.tryParseBinary(payload);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('01020304'));
|
||||
expect(parsed.index, equals(7));
|
||||
});
|
||||
});
|
||||
|
||||
group('VoicePacket binary format', () {
|
||||
test('constructs binary datagram from actual packet data', () {
|
||||
final actualCodec2 = Uint8List.fromList([
|
||||
0xD3,
|
||||
0x19,
|
||||
0x7A,
|
||||
0x00,
|
||||
0xFE,
|
||||
0x44,
|
||||
0xC1,
|
||||
0x2B,
|
||||
0x88,
|
||||
]);
|
||||
|
||||
final pkt = VoicePacket(
|
||||
sessionId: '01020304',
|
||||
mode: VoicePacketMode.mode1300,
|
||||
index: 2,
|
||||
total: 5,
|
||||
codec2Data: actualCodec2,
|
||||
);
|
||||
|
||||
final datagram = pkt.encodeBinary();
|
||||
expect(datagram[0], equals(0x56)); // magic 'V'
|
||||
expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4])));
|
||||
expect(datagram[5], equals(2));
|
||||
expect(datagram.sublist(6), equals(actualCodec2));
|
||||
|
||||
final parsed = VoicePacket.tryParseBinary(datagram);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sessionId, equals('01020304'));
|
||||
expect(parsed.mode, equals(VoicePacketMode.mode1300));
|
||||
expect(parsed.index, equals(2));
|
||||
expect(parsed.total, equals(0));
|
||||
expect(parsed.codec2Data, equals(actualCodec2));
|
||||
});
|
||||
});
|
||||
|
||||
group('VoiceWaveform', () {
|
||||
test('builds bars from packet bytes', () {
|
||||
final packet = VoicePacket(
|
||||
sessionId: '1234abcd',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
index: 0,
|
||||
total: 1,
|
||||
codec2Data: Uint8List.fromList([
|
||||
0,
|
||||
255,
|
||||
10,
|
||||
245,
|
||||
120,
|
||||
130,
|
||||
64,
|
||||
192,
|
||||
32,
|
||||
224,
|
||||
]),
|
||||
);
|
||||
|
||||
final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8);
|
||||
expect(bars.length, equals(8));
|
||||
expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue);
|
||||
expect(bars.any((v) => v > 0.2), isTrue);
|
||||
});
|
||||
|
||||
test('returns zeros for missing packet data', () {
|
||||
final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6);
|
||||
expect(bars, equals(List<double>.filled(6, 0.0)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user