- Add backtest and market socket handlers for API and Worker - Add proto definitions for backtest and market services - Update client socket integration and tests - Generate protobuf code for Go and Dart - Archive completed agent tasks under agent-task/archive/2026/05/
511 lines
17 KiB
Dart
511 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:fixnum/fixnum.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:proto_socket/proto_socket.dart';
|
|
|
|
import 'package:alt_client/src/generated/alt/v1/backtest.pb.dart';
|
|
import 'package:alt_client/src/generated/alt/v1/common.pb.dart';
|
|
import 'package:alt_client/src/generated/alt/v1/market.pb.dart';
|
|
import 'package:alt_client/src/integrations/socket/alt_socket_client.dart';
|
|
import 'package:alt_client/src/integrations/socket/socket_endpoint.dart';
|
|
|
|
class FakeWebSocket extends Stream<dynamic> implements WebSocket {
|
|
final StreamController<dynamic> _controller = StreamController<dynamic>();
|
|
final List<List<int>> sentBytes = [];
|
|
bool isClosed = false;
|
|
|
|
@override
|
|
void add(dynamic data) {
|
|
if (data is List<int>) {
|
|
sentBytes.add(data);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> close([int? code, String? reason]) async {
|
|
isClosed = true;
|
|
await _controller.close();
|
|
}
|
|
|
|
void feedFromServer(List<int> data) {
|
|
_controller.add(data);
|
|
}
|
|
|
|
@override
|
|
StreamSubscription<dynamic> listen(
|
|
void Function(dynamic event)? onData, {
|
|
Function? onError,
|
|
void Function()? onDone,
|
|
bool? cancelOnError,
|
|
}) {
|
|
return _controller.stream.listen(
|
|
onData,
|
|
onError: onError,
|
|
onDone: onDone,
|
|
cancelOnError: cancelOnError,
|
|
);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
void main() {
|
|
group('AltSocketEndpoint tests', () {
|
|
test('default constructor values', () {
|
|
const endpoint = AltSocketEndpoint();
|
|
expect(endpoint.host, equals('127.0.0.1'));
|
|
expect(endpoint.port, equals(8080));
|
|
expect(endpoint.path, equals('/socket'));
|
|
expect(endpoint.toString(), equals('ws://127.0.0.1:8080/socket'));
|
|
});
|
|
|
|
test('custom values', () {
|
|
const endpoint = AltSocketEndpoint(
|
|
host: 'api.example.com',
|
|
port: 443,
|
|
path: '/ws',
|
|
);
|
|
expect(endpoint.host, equals('api.example.com'));
|
|
expect(endpoint.port, equals(443));
|
|
expect(endpoint.path, equals('/ws'));
|
|
expect(endpoint.toString(), equals('ws://api.example.com:443/ws'));
|
|
});
|
|
|
|
test('equality and hashCode', () {
|
|
const e1 = AltSocketEndpoint(host: '1.2.3.4', port: 9000, path: '/a');
|
|
const e2 = AltSocketEndpoint(host: '1.2.3.4', port: 9000, path: '/a');
|
|
const e3 = AltSocketEndpoint(host: '1.2.3.4', port: 9000, path: '/b');
|
|
|
|
expect(e1, equals(e2));
|
|
expect(e1.hashCode, equals(e2.hashCode));
|
|
expect(e1, isNot(equals(e3)));
|
|
});
|
|
});
|
|
|
|
group('AltSocketClient tests', () {
|
|
late FakeWebSocket fakeWs;
|
|
late AltSocketClient client;
|
|
|
|
setUp(() {
|
|
fakeWs = FakeWebSocket();
|
|
client = AltSocketClient(
|
|
fakeWs,
|
|
heartbeatIntervalTime: 1,
|
|
heartbeatWaitTime: 1,
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
await client.close();
|
|
});
|
|
|
|
test('client initialization and parser registration', () {
|
|
expect(client.isAlive, isTrue);
|
|
|
|
final helloRequestType =
|
|
HelloRequest.getDefault().info_.qualifiedMessageName;
|
|
final helloResponseType =
|
|
HelloResponse.getDefault().info_.qualifiedMessageName;
|
|
|
|
expect(client.getGenerator<HelloRequest>(helloRequestType), isNotNull);
|
|
expect(client.getGenerator<HelloResponse>(helloResponseType), isNotNull);
|
|
});
|
|
|
|
test('hello handshake request-response loop', () async {
|
|
final helloFuture = client.hello(timeout: const Duration(seconds: 1));
|
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(fakeWs.sentBytes, isNotEmpty);
|
|
final firstSentPacketBytes = fakeWs.sentBytes.first;
|
|
final sentPacket = PacketBase.fromBuffer(firstSentPacketBytes);
|
|
|
|
final helloRequestType =
|
|
HelloRequest.getDefault().info_.qualifiedMessageName;
|
|
expect(sentPacket.typeName, equals(helloRequestType));
|
|
|
|
final sentHelloRequest = HelloRequest.fromBuffer(sentPacket.data);
|
|
expect(sentHelloRequest.clientName, equals('alt-client'));
|
|
expect(sentHelloRequest.clientVersion, equals('dev'));
|
|
expect(sentHelloRequest.altProtocolVersion, equals('alt.v1'));
|
|
|
|
final expectedResponse = HelloResponse()
|
|
..serverName = 'alt-server'
|
|
..serverVersion = '1.0.0'
|
|
..altProtocolVersion = 'alt.v1';
|
|
|
|
final responsePacket = PacketBase()
|
|
..typeName = HelloResponse.getDefault().info_.qualifiedMessageName
|
|
..nonce = 100
|
|
..responseNonce = sentPacket.nonce
|
|
..data = expectedResponse.writeToBuffer();
|
|
|
|
fakeWs.feedFromServer(responsePacket.writeToBuffer());
|
|
|
|
final response = await helloFuture;
|
|
|
|
expect(response.serverName, equals('alt-server'));
|
|
expect(response.serverVersion, equals('1.0.0'));
|
|
expect(response.altProtocolVersion, equals('alt.v1'));
|
|
});
|
|
|
|
// Drains the request sent by [trigger], asserts the sent packet type/payload
|
|
// through [onSent], then feeds a matching response packet back so the worker
|
|
// -> API -> client backtest boundary round-trips consistently.
|
|
Future<PacketBase> roundTrip({
|
|
required Future<void> Function() trigger,
|
|
required void Function(PacketBase sentPacket) onSent,
|
|
required String responseTypeName,
|
|
required List<int> responseData,
|
|
}) async {
|
|
final sentCountBefore = fakeWs.sentBytes.length;
|
|
unawaited(trigger());
|
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(fakeWs.sentBytes.length, greaterThan(sentCountBefore));
|
|
final sentPacket = PacketBase.fromBuffer(fakeWs.sentBytes.last);
|
|
onSent(sentPacket);
|
|
|
|
final responsePacket = PacketBase()
|
|
..typeName = responseTypeName
|
|
..nonce = 200
|
|
..responseNonce = sentPacket.nonce
|
|
..data = responseData;
|
|
fakeWs.feedFromServer(responsePacket.writeToBuffer());
|
|
|
|
return sentPacket;
|
|
}
|
|
|
|
test('listBacktestRuns request-response loop', () async {
|
|
final request = ListBacktestRunsRequest()
|
|
..status = BacktestRunStatus.BACKTEST_RUN_STATUS_SUCCEEDED;
|
|
final expectedResponse = ListBacktestRunsResponse()
|
|
..runs.add(BacktestRun()
|
|
..id = 'run-1'
|
|
..status = BacktestRunStatus.BACKTEST_RUN_STATUS_SUCCEEDED);
|
|
|
|
final future = client.listBacktestRuns(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
ListBacktestRunsRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest = ListBacktestRunsRequest.fromBuffer(sentPacket.data);
|
|
expect(
|
|
sentRequest.status,
|
|
equals(BacktestRunStatus.BACKTEST_RUN_STATUS_SUCCEEDED),
|
|
);
|
|
},
|
|
responseTypeName:
|
|
ListBacktestRunsResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.runs, hasLength(1));
|
|
expect(response.runs.first.id, equals('run-1'));
|
|
expect(
|
|
response.runs.first.status,
|
|
equals(BacktestRunStatus.BACKTEST_RUN_STATUS_SUCCEEDED),
|
|
);
|
|
});
|
|
|
|
test('getBacktestRunDetail request-response loop', () async {
|
|
final request = GetBacktestRunDetailRequest()..runId = 'run-7';
|
|
final expectedResponse = GetBacktestRunDetailResponse()
|
|
..run = (BacktestRun()..id = 'run-7')
|
|
..result = (BacktestResult()..runId = 'run-7');
|
|
|
|
final future = client.getBacktestRunDetail(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
GetBacktestRunDetailRequest.getDefault()
|
|
.info_
|
|
.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest =
|
|
GetBacktestRunDetailRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.runId, equals('run-7'));
|
|
},
|
|
responseTypeName: GetBacktestRunDetailResponse.getDefault()
|
|
.info_
|
|
.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.run.id, equals('run-7'));
|
|
expect(response.result.runId, equals('run-7'));
|
|
});
|
|
|
|
test('getBacktestResult request-response loop', () async {
|
|
final request = GetBacktestResultRequest()..runId = 'run-9';
|
|
final expectedResponse = GetBacktestResultResponse()
|
|
..result = (BacktestResult()..runId = 'run-9');
|
|
|
|
final future = client.getBacktestResult(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
GetBacktestResultRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest =
|
|
GetBacktestResultRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.runId, equals('run-9'));
|
|
},
|
|
responseTypeName:
|
|
GetBacktestResultResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.result.runId, equals('run-9'));
|
|
});
|
|
|
|
test('compareBacktestRuns request-response loop', () async {
|
|
final request = CompareBacktestRunsRequest()
|
|
..runIds.addAll(['run-1', 'run-2']);
|
|
final expectedResponse = CompareBacktestRunsResponse()
|
|
..results.add(BacktestResult()..runId = 'run-1')
|
|
..results.add(BacktestResult()..runId = 'run-2');
|
|
|
|
final future = client.compareBacktestRuns(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
CompareBacktestRunsRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest =
|
|
CompareBacktestRunsRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.runIds, equals(['run-1', 'run-2']));
|
|
},
|
|
responseTypeName:
|
|
CompareBacktestRunsResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.results, hasLength(2));
|
|
expect(
|
|
response.results.map((r) => r.runId),
|
|
equals(['run-1', 'run-2']),
|
|
);
|
|
});
|
|
|
|
test('startBacktest command request-response loop', () async {
|
|
final request = StartBacktestRequest()
|
|
..spec = (BacktestRunSpec()
|
|
..strategyId = 'strategy-1'
|
|
..market = Market.MARKET_KR
|
|
..timeframe = Timeframe.TIMEFRAME_DAILY
|
|
..fromUnixMs = Int64(1000)
|
|
..toUnixMs = Int64(2000));
|
|
final expectedResponse = StartBacktestResponse()
|
|
..run = (BacktestRun()
|
|
..id = 'run-new'
|
|
..status = BacktestRunStatus.BACKTEST_RUN_STATUS_PENDING);
|
|
|
|
final future = client.startBacktest(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
StartBacktestRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest = StartBacktestRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.spec.strategyId, equals('strategy-1'));
|
|
expect(sentRequest.spec.market, equals(Market.MARKET_KR));
|
|
expect(
|
|
sentRequest.spec.timeframe,
|
|
equals(Timeframe.TIMEFRAME_DAILY),
|
|
);
|
|
},
|
|
responseTypeName:
|
|
StartBacktestResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.run.id, equals('run-new'));
|
|
expect(
|
|
response.run.status,
|
|
equals(BacktestRunStatus.BACKTEST_RUN_STATUS_PENDING),
|
|
);
|
|
});
|
|
|
|
test('listInstruments market query request-response loop', () async {
|
|
final request = ListInstrumentsRequest()
|
|
..market = Market.MARKET_US
|
|
..provider = 'kis';
|
|
final expectedResponse = ListInstrumentsResponse()
|
|
..instruments.add(Instrument()
|
|
..id = 'instrument-1'
|
|
..market = Market.MARKET_US
|
|
..symbol = 'AAPL');
|
|
|
|
final future = client.listInstruments(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
ListInstrumentsRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest =
|
|
ListInstrumentsRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.market, equals(Market.MARKET_US));
|
|
expect(sentRequest.provider, equals('kis'));
|
|
},
|
|
responseTypeName:
|
|
ListInstrumentsResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.instruments, hasLength(1));
|
|
expect(response.instruments.first.id, equals('instrument-1'));
|
|
expect(response.instruments.first.symbol, equals('AAPL'));
|
|
});
|
|
|
|
test('listBars market query request-response loop', () async {
|
|
final request = ListBarsRequest()
|
|
..instrumentId = 'instrument-1'
|
|
..timeframe = Timeframe.TIMEFRAME_DAILY
|
|
..fromUnixMs = Int64(1000)
|
|
..toUnixMs = Int64(2000);
|
|
final expectedResponse = ListBarsResponse()
|
|
..bars.add(Bar()
|
|
..instrumentId = 'instrument-1'
|
|
..timeframe = Timeframe.TIMEFRAME_DAILY
|
|
..timestampUnixMs = Int64(1500));
|
|
|
|
final future = client.listBars(
|
|
request,
|
|
timeout: const Duration(seconds: 1),
|
|
);
|
|
|
|
await roundTrip(
|
|
trigger: () => future,
|
|
onSent: (sentPacket) {
|
|
expect(
|
|
sentPacket.typeName,
|
|
equals(
|
|
ListBarsRequest.getDefault().info_.qualifiedMessageName,
|
|
),
|
|
);
|
|
final sentRequest = ListBarsRequest.fromBuffer(sentPacket.data);
|
|
expect(sentRequest.instrumentId, equals('instrument-1'));
|
|
expect(
|
|
sentRequest.timeframe,
|
|
equals(Timeframe.TIMEFRAME_DAILY),
|
|
);
|
|
},
|
|
responseTypeName:
|
|
ListBarsResponse.getDefault().info_.qualifiedMessageName,
|
|
responseData: expectedResponse.writeToBuffer(),
|
|
);
|
|
|
|
final response = await future;
|
|
expect(response.bars, hasLength(1));
|
|
expect(response.bars.first.instrumentId, equals('instrument-1'));
|
|
expect(
|
|
response.bars.first.timeframe,
|
|
equals(Timeframe.TIMEFRAME_DAILY),
|
|
);
|
|
});
|
|
|
|
// Feeds a response carrying the wrong message type for the pending request
|
|
// so the typed `sendRequest` boundary surfaces a StateError instead of
|
|
// silently completing. Verifies the new API wrappers propagate errors the
|
|
// same way the existing backtest query wrappers do.
|
|
test('new API wrappers propagate response type mismatch errors', () async {
|
|
Future<void> expectTypeMismatch(Future<dynamic> future) async {
|
|
final sentCountBefore = fakeWs.sentBytes.length;
|
|
unawaited(future);
|
|
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(fakeWs.sentBytes.length, greaterThan(sentCountBefore));
|
|
final sentPacket = PacketBase.fromBuffer(fakeWs.sentBytes.last);
|
|
|
|
final wrongResponse = HelloResponse()..serverName = 'wrong-type';
|
|
final responsePacket = PacketBase()
|
|
..typeName = HelloResponse.getDefault().info_.qualifiedMessageName
|
|
..nonce = 300
|
|
..responseNonce = sentPacket.nonce
|
|
..data = wrongResponse.writeToBuffer();
|
|
fakeWs.feedFromServer(responsePacket.writeToBuffer());
|
|
|
|
await expectLater(future, throwsA(isA<StateError>()));
|
|
}
|
|
|
|
await expectTypeMismatch(
|
|
client.startBacktest(
|
|
StartBacktestRequest(),
|
|
timeout: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
await expectTypeMismatch(
|
|
client.listInstruments(
|
|
ListInstrumentsRequest(),
|
|
timeout: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
await expectTypeMismatch(
|
|
client.listBars(
|
|
ListBarsRequest(),
|
|
timeout: const Duration(seconds: 1),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|