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 implements WebSocket { final StreamController _controller = StreamController(); final List> sentBytes = []; bool isClosed = false; @override void add(dynamic data) { if (data is List) { sentBytes.add(data); } } @override Future close([int? code, String? reason]) async { isClosed = true; await _controller.close(); } void feedFromServer(List data) { _controller.add(data); } @override StreamSubscription 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(18030)); expect(endpoint.path, equals('/socket')); expect(endpoint.toString(), equals('ws://127.0.0.1:18030/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(helloRequestType), isNotNull); expect(client.getGenerator(helloResponseType), isNotNull); }); test('hello handshake request-response loop', () async { final helloFuture = client.hello(timeout: const Duration(seconds: 1)); await Future.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 roundTrip({ required Future Function() trigger, required void Function(PacketBase sentPacket) onSent, required String responseTypeName, required List responseData, }) async { final sentCountBefore = fakeWs.sentBytes.length; unawaited(trigger()); await Future.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 expectTypeMismatch(Future future) async { final sentCountBefore = fakeWs.sentBytes.length; unawaited(future); await Future.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())); } 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), ), ); }); }); }