Starter display: boundary v0 client, readout, mark button, CI
Connects to svc-gw over one WebSocket, heartbeats, treats timeout_ms of silence as a dead link, and retries commands under the same id until acked. Shows link state, signals with stale/null/missing told apart, the raw stream, a mock banner on the surface, and a Mark button that stamps the recording. Android: keeps the screen on and binds the process to the USB NCM network when the gateway is on it. CI mirrors android-stuff's host-mode runner setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:display/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);
|
||||
});
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:display/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user