You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/test/services/repeater_command_service_te...

278 lines
9.8 KiB

// #528 (epic #473): a repeater CLI reply that arrives with no command waiting
// for it used to hit `if (commandId.isEmpty) return;` and vanish. No log, no
// UI, nothing. Since a command's window can close before the app has even
// finished fetching the message (MSG_WAITING then SYNC_NEXT_MESSAGE), that
// made "the command ran but the response was never reported" the normal
// outcome rather than an edge case.
//
// These tests pin the contract that no reply is ever dropped silently.
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/repeater_command_service.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
Contact _repeater() => Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
name: 'Test Repeater',
type: 2,
pathLength: 0,
path: Uint8List(0),
lastSeen: DateTime.fromMillisecondsSinceEpoch(0),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late RepeaterCommandService service;
late Contact repeater;
late List<UnmatchedRepeaterResponse> surfaced;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
service = RepeaterCommandService(MeshCoreConnector());
repeater = _repeater();
surfaced = [];
service.onUnmatchedResponse = surfaced.add;
});
tearDown(() => service.dispose());
test('a reply with no pending command is surfaced, not discarded', () {
service.handleResponse(repeater, 'Version: 1.2.3');
expect(surfaced, hasLength(1));
expect(surfaced.single.response, 'Version: 1.2.3');
expect(surfaced.single.repeaterKeyHex, repeater.publicKeyHex);
});
test('a reply for a command that already timed out names that command', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.handleResponse(repeater, 'A3|Version: 1.2.3');
expect(surfaced, hasLength(1));
final reply = surfaced.single;
expect(reply.isLateReply, isTrue);
expect(reply.command, 'ver');
expect(reply.response, 'Version: 1.2.3', reason: 'prefix must be stripped');
expect(reply.sinceTimeout, isNotNull);
});
test('an unattributable reply is still surfaced, just without a command', () {
service.handleResponse(repeater, 'ZZ|unsolicited chatter');
expect(surfaced, hasLength(1));
expect(surfaced.single.isLateReply, isFalse);
expect(surfaced.single.command, isNull);
expect(surfaced.single.response, 'unsolicited chatter');
});
test('an expired command is only consumed once', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.handleResponse(repeater, 'A3|first');
service.handleResponse(repeater, 'A3|second');
expect(surfaced, hasLength(2));
expect(surfaced[0].command, 'ver');
expect(
surfaced[1].command,
isNull,
reason: 'the record is consumed by the first reply that claims it',
);
});
test('a reply is never swallowed when no callback is wired', () {
service.onUnmatchedResponse = null;
// The contract is that this cannot throw and cannot hang. The logging path
// still runs; the absence of a listener must not resurrect the silent drop
// as an unhandled error.
expect(
() => service.handleResponse(repeater, 'orphan reply'),
returnsNormally,
);
});
test('dispose clears the late-reply records', () {
service.recordExpiredCommandForTest('A3|', 'ver');
service.dispose();
// Re-arm a listener on the disposed service and confirm the stale record is
// gone, so a reply after disposal cannot be mis-attributed to it.
final seen = <UnmatchedRepeaterResponse>[];
service.onUnmatchedResponse = seen.add;
service.handleResponse(repeater, 'A3|Version: 1.2.3');
expect(seen.single.command, isNull);
});
group('timeout reporting (#531)', () {
// The old message printed (timeoutMs / 1000).ceil(), so every window in
// (4000, 5000] announced "5 seconds" and the figure shown was never the
// one armed. The owner's 0-hop window was 4074 ms and it claimed 5.
test('reports the armed window to one decimal, not rounded up', () {
const e = RepeaterCommandTimeout(command: 'ver', timeoutMs: 4074);
expect(e.secondsText, '4.1');
expect(e.toString(), 'Command timed out after 4.1 seconds');
});
test('does not round a sub-second remainder up to the next second', () {
// 28748 ms is the new CLI budget on the owner's preset. ceil() would say
// 29; the armed window is 28.7.
const e = RepeaterCommandTimeout(command: 'status', timeoutMs: 28748);
expect(e.secondsText, '28.7');
});
test('distinct windows in the same second are distinguishable', () {
// The defect's signature: 4001 and 4999 both printed "5 seconds".
const a = RepeaterCommandTimeout(command: 'a', timeoutMs: 4001);
const b = RepeaterCommandTimeout(command: 'b', timeoutMs: 4999);
expect(a.secondsText, isNot(b.secondsText));
});
test('carries the command so a caller can name what timed out', () {
const e = RepeaterCommandTimeout(command: 'get tx', timeoutMs: 1234);
expect(e.command, 'get tx');
expect(e.timeoutMs, 1234);
});
});
group('reply correlation (#532)', () {
// A reply carrying a prefix identifies itself. Before this fix, a prefix
// that matched nothing pending fell through to "first pending command for
// this repeater", so a straggler from an expired command could complete an
// unrelated one and report its output as that command's result.
//
// Not reachable from today's callers, which all pass retries: 1 and await
// sequentially, but live the moment anything issues concurrent commands.
test(
'a stale prefix does NOT complete a different in-flight command',
() async {
final other = service.registerPendingForTest(
repeater.publicKeyHex,
'B7|',
);
var otherCompleted = false;
unawaited(other.then((_) => otherCompleted = true));
// 'A3|' expired earlier and is no longer pending.
service.recordExpiredCommandForTest('A3|', 'ver');
service.handleResponse(repeater, 'A3|v1.16.0');
await Future<void>.delayed(Duration.zero);
expect(
otherCompleted,
isFalse,
reason: "the 'B7|' command must not be completed by 'A3|' output",
);
expect(surfaced, hasLength(1));
expect(surfaced.single.command, 'ver');
expect(surfaced.single.response, 'v1.16.0');
},
);
test(
'an unknown prefix with nothing expired is surfaced, not misapplied',
() async {
final other = service.registerPendingForTest(
repeater.publicKeyHex,
'B7|',
);
var otherCompleted = false;
unawaited(other.then((_) => otherCompleted = true));
service.handleResponse(repeater, 'ZZ|orphan output');
await Future<void>.delayed(Duration.zero);
expect(otherCompleted, isFalse);
expect(surfaced.single.command, isNull);
expect(surfaced.single.response, 'orphan output');
},
);
test('a matching prefix still completes its own command', () async {
final own = service.registerPendingForTest(repeater.publicKeyHex, 'C1|');
service.handleResponse(repeater, 'C1|hello');
expect(await own, 'hello');
expect(surfaced, isEmpty);
});
test('an unprefixed reply still falls back to the pending command', () async {
// Preserved behaviour: with no correlation token there is nothing else to
// match on, so position is all we have.
final own = service.registerPendingForTest(repeater.publicKeyHex, 'D2|');
service.handleResponse(repeater, 'no prefix here');
expect(await own, 'no prefix here');
expect(surfaced, isEmpty);
});
});
group('unprefixed reply ambiguity (Gemini review)', () {
// #532 constrained PREFIXED replies. An unprefixed one still fell back to
// "first pending command for this repeater", which with two or more in
// flight is map-iteration order, so a caller could receive another
// command's output.
//
// The fallback cannot simply be deleted: firmware only echoes the prefix
// when the command exceeds four characters including it
// (simple_repeater/MyMesh.cpp, `strlen(command) > 4 && command[2] == '|'`),
// so a very short command legitimately replies without one.
test(
'with exactly one pending, an unprefixed reply still resolves it',
() async {
final only = service.registerPendingForTest(
repeater.publicKeyHex,
'E1|',
);
service.handleResponse(repeater, 'bare payload');
expect(await only, 'bare payload');
expect(surfaced, isEmpty);
},
);
test('with two pending, an unprefixed reply resolves NEITHER', () async {
final first = service.registerPendingForTest(
repeater.publicKeyHex,
'E1|',
);
final second = service.registerPendingForTest(
repeater.publicKeyHex,
'E2|',
);
var firstDone = false, secondDone = false;
unawaited(first.then((_) => firstDone = true));
unawaited(second.then((_) => secondDone = true));
service.handleResponse(repeater, 'ambiguous payload');
await Future<void>.delayed(Duration.zero);
expect(firstDone, isFalse);
expect(secondDone, isFalse);
expect(
surfaced.single.response,
'ambiguous payload',
reason: 'ambiguity must be surfaced, never guessed at',
);
expect(surfaced.single.command, isNull);
});
});
}

Powered by TurnKey Linux.