Files
app-flutter/test/boundary_test.dart
mikkeli 4cf7a786d6
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 27s
App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
The shell (shell.dart): a drawer from the left edge or each page's menu button.
Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the
rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright,
build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals,
signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its
own navigator, so a run survives switching views. Back goes inside Captures, then to
the landing view, then out. The mock banner sits over every page.

main.dart is startup only; the old home screen is pages/signals.dart; dialogs and
sheets moved to actions/; the protocol client, USB binding and Reading to link/.
lib/README.md catalogues it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-14 14:08:56 +09:00

113 lines
4.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:display/link/boundary.dart';
import 'package:flutter_test/flutter_test.dart';
/// A minimal stand-in for svc-gw: hello, then whatever the test sends.
class FakeGateway {
late HttpServer server;
final sockets = <WebSocket>[];
final received = <Map<String, dynamic>>[];
bool ackCommands = true;
Future<void> start({bool mock = false}) async {
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.transform(WebSocketTransformer()).listen((ws) {
sockets.add(ws);
ws.add(jsonEncode({
't': 'hello', 'proto': 0, 'server': 'fake', 'host': 'test', 'source': mock ? 'mock' : 'none',
'mock': mock, 'hb_ms': 100, 'timeout_ms': 400,
}));
ws.listen((raw) {
final m = jsonDecode(raw as String) as Map<String, dynamic>;
received.add(m);
if (m['t'] == 'cmd' && ackCommands) {
ws.add(jsonEncode({'t': 'ack', 'id': m['id'], 'ok': true, 'detail': 'ok'}));
}
});
});
}
String get url => 'ws://127.0.0.1:${server.port}/';
}
Future<void> until(bool Function() cond, {Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (!cond()) {
if (DateTime.now().isAfter(end)) throw TimeoutException('condition not met');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
void main() {
test('connects, takes state, flags mock, heartbeats', () async {
final gw = FakeGateway();
await gw.start(mock: true);
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
expect(b.mock, isTrue);
gw.sockets.first.add(jsonEncode({
't': 'state',
'seq': 1,
'signals': {
'engine_rpm': {'timestamp': 0, 'monotonic': 0, 'value': 820, 'unit': 'rpm', 'mock': true},
},
}));
await until(() => b.signals.containsKey('engine_rpm'));
await until(() => gw.received.where((m) => m['t'] == 'hb').length >= 2);
await b.stop();
await gw.server.close(force: true);
});
test('silence past timeout_ms drops the link and reconnects', () async {
final gw = FakeGateway();
await gw.start();
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
// The fake never sends heartbeats, so the watchdog must fire after 400 ms.
await until(() => gw.sockets.length >= 2, timeout: const Duration(seconds: 4));
await b.stop();
await gw.server.close(force: true);
});
test('commands are retried under the same id until acked', () async {
final gw = FakeGateway()..ackCommands = false;
await gw.start();
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
final id = b.command('mark', {'text': 'brake'});
await until(() => gw.received.any((m) => m['t'] == 'cmd'));
// Drop the connection unacked; the command must be resent after reconnect.
gw.ackCommands = true;
await gw.sockets.first.close();
await until(() => b.pending.isEmpty);
final sent = gw.received.where((m) => m['t'] == 'cmd').toList();
expect(sent.length, greaterThanOrEqualTo(2));
expect(sent.every((m) => m['id'] == id), isTrue);
expect(sent.last['args']['delay_ms'], greaterThan(0));
expect(b.acks.first.ok, isTrue);
await b.stop();
await gw.server.close(force: true);
});
test('a signal that drops out stays known as missing', () async {
final gw = FakeGateway();
await gw.start();
final b = Boundary(gw.url)..start();
await until(() => b.state == LinkState.up);
Map<String, dynamic> env(num v) => {'timestamp': 0, 'monotonic': 0, 'value': v, 'unit': 'x'};
gw.sockets.first.add(jsonEncode({'t': 'state', 'seq': 1, 'signals': {'a': env(1), 'b': env(2)}}));
await until(() => b.signals.length == 2);
gw.sockets.first.add(jsonEncode({'t': 'state', 'seq': 2, 'signals': {'a': env(3)}}));
await until(() => b.signals.length == 1);
expect(b.seen.keys, containsAll(['a', 'b']));
expect(b.signals.containsKey('b'), isFalse);
b.retarget(gw.url);
expect(b.seen, isEmpty);
await b.stop();
await gw.server.close(force: true);
});
}