feat: add suitelink client runtime and test harness

This commit is contained in:
Joseph Doherty
2026-03-16 16:46:32 -04:00
parent 731bfe2237
commit c278f98496
27 changed files with 2515 additions and 15 deletions

View File

@@ -0,0 +1,81 @@
namespace SuiteLink.Client.IntegrationTests;
internal sealed record class IntegrationSettings(
SuiteLinkConnectionOptions Connection,
string? BooleanTag,
string? IntegerTag,
string? FloatTag,
string? StringTag)
{
public static bool TryLoad(out IntegrationSettings settings, out string reason)
{
settings = null!;
var enabled = Environment.GetEnvironmentVariable("SUITELINK_IT_ENABLED");
if (!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase))
{
reason = "Set SUITELINK_IT_ENABLED=true to run live integration tests.";
return false;
}
if (!TryGetRequired("SUITELINK_IT_HOST", out var host, out reason) ||
!TryGetRequired("SUITELINK_IT_APPLICATION", out var application, out reason) ||
!TryGetRequired("SUITELINK_IT_TOPIC", out var topic, out reason) ||
!TryGetRequired("SUITELINK_IT_CLIENT_NAME", out var clientName, out reason) ||
!TryGetRequired("SUITELINK_IT_CLIENT_NODE", out var clientNode, out reason) ||
!TryGetRequired("SUITELINK_IT_USER_NAME", out var userName, out reason) ||
!TryGetRequired("SUITELINK_IT_SERVER_NODE", out var serverNode, out reason))
{
return false;
}
var timezone = Environment.GetEnvironmentVariable("SUITELINK_IT_TIMEZONE");
var port = 5413;
var portRaw = Environment.GetEnvironmentVariable("SUITELINK_IT_PORT");
if (!string.IsNullOrWhiteSpace(portRaw) && !int.TryParse(portRaw, out port))
{
reason = "SUITELINK_IT_PORT must be a valid integer.";
return false;
}
var connection = new SuiteLinkConnectionOptions(
host: host,
application: application,
topic: topic,
clientName: clientName,
clientNode: clientNode,
userName: userName,
serverNode: serverNode,
timezone: timezone,
port: port);
settings = new IntegrationSettings(
Connection: connection,
BooleanTag: Normalize(Environment.GetEnvironmentVariable("SUITELINK_IT_BOOL_TAG")),
IntegerTag: Normalize(Environment.GetEnvironmentVariable("SUITELINK_IT_INT_TAG")),
FloatTag: Normalize(Environment.GetEnvironmentVariable("SUITELINK_IT_FLOAT_TAG")),
StringTag: Normalize(Environment.GetEnvironmentVariable("SUITELINK_IT_STRING_TAG")));
reason = string.Empty;
return true;
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static bool TryGetRequired(string name, out string value, out string reason)
{
value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(value))
{
reason = string.Empty;
return true;
}
reason = $"Missing required environment variable: {name}.";
return false;
}
}

View File

@@ -0,0 +1,36 @@
# SuiteLink Integration Tests
These tests are intentionally safe by default and run only when explicitly enabled.
## Enable
Set:
- `SUITELINK_IT_ENABLED=true`
Required connection variables:
- `SUITELINK_IT_HOST`
- `SUITELINK_IT_APPLICATION`
- `SUITELINK_IT_TOPIC`
- `SUITELINK_IT_CLIENT_NAME`
- `SUITELINK_IT_CLIENT_NODE`
- `SUITELINK_IT_USER_NAME`
- `SUITELINK_IT_SERVER_NODE`
Optional connection variables:
- `SUITELINK_IT_PORT` (default `5413`)
- `SUITELINK_IT_TIMEZONE` (defaults to `UTC` via `SuiteLinkConnectionOptions`)
Optional tag variables (tests run only for the tags provided):
- `SUITELINK_IT_BOOL_TAG`
- `SUITELINK_IT_INT_TAG`
- `SUITELINK_IT_FLOAT_TAG`
- `SUITELINK_IT_STRING_TAG`
## Notes
- If integration settings are missing, tests return immediately and do not perform network calls.
- These tests are intended as a live harness, not deterministic CI tests.

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SuiteLink.Client\SuiteLink.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,109 @@
namespace SuiteLink.Client.IntegrationTests;
public sealed class TagRoundTripTests
{
[Fact]
public async Task BooleanTag_RoundTrip_WhenConfigured()
{
if (!TryGetTagSettings(out var settings, out var tagName, "bool"))
{
return;
}
await RunRoundTripAsync(
settings,
tagName,
SuiteLinkValue.FromBoolean(true),
value => value.TryGetBoolean(out _));
}
[Fact]
public async Task IntegerTag_RoundTrip_WhenConfigured()
{
if (!TryGetTagSettings(out var settings, out var tagName, "int"))
{
return;
}
await RunRoundTripAsync(
settings,
tagName,
SuiteLinkValue.FromInt32(42),
value => value.TryGetInt32(out _));
}
[Fact]
public async Task FloatTag_RoundTrip_WhenConfigured()
{
if (!TryGetTagSettings(out var settings, out var tagName, "float"))
{
return;
}
await RunRoundTripAsync(
settings,
tagName,
SuiteLinkValue.FromFloat32(12.25f),
value => value.TryGetFloat32(out _));
}
[Fact]
public async Task StringTag_RoundTrip_WhenConfigured()
{
if (!TryGetTagSettings(out var settings, out var tagName, "string"))
{
return;
}
await RunRoundTripAsync(
settings,
tagName,
SuiteLinkValue.FromString("integration-test"),
value => value.TryGetString(out _));
}
private static bool TryGetTagSettings(
out IntegrationSettings settings,
out string tagName,
string type)
{
settings = null!;
tagName = string.Empty;
if (!IntegrationSettings.TryLoad(out settings, out _))
{
return false;
}
tagName = type switch
{
"bool" => settings.BooleanTag ?? string.Empty,
"int" => settings.IntegerTag ?? string.Empty,
"float" => settings.FloatTag ?? string.Empty,
"string" => settings.StringTag ?? string.Empty,
_ => string.Empty
};
return !string.IsNullOrWhiteSpace(tagName);
}
private static async Task RunRoundTripAsync(
IntegrationSettings settings,
string tagName,
SuiteLinkValue writeValue,
Func<SuiteLinkValue, bool> typeCheck)
{
await using var client = new SuiteLinkClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await client.ConnectAsync(settings.Connection, cts.Token);
var readBefore = await client.ReadAsync(tagName, TimeSpan.FromSeconds(10), cts.Token);
Assert.True(typeCheck(readBefore.Value));
await client.WriteAsync(tagName, writeValue, cts.Token);
var readAfter = await client.ReadAsync(tagName, TimeSpan.FromSeconds(10), cts.Token);
Assert.True(typeCheck(readAfter.Value));
}
}

View File

@@ -0,0 +1,10 @@
namespace SuiteLink.Client.Tests.Fixtures;
internal static class FixtureBytes
{
public static byte[] Read(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
return File.ReadAllBytes(path);
}
}

View File

@@ -0,0 +1,25 @@
# SuiteLink Test Fixtures
This folder stores small binary packet fixtures used by protocol codec tests.
## Source and intent
- These fixtures are based on fixed vectors already used in tests and aligned with the reverse-engineered SuiteLink dissector behavior used in this repo's design docs.
- They are intentionally minimal and validate specific assumptions, not full protocol coverage.
## Fixtures
- `handshake-ack-normal.bin`
- Bytes: `06 00 01 00 A1 B2 C3 A5`
- Assumption validated: normal handshake ACK frame type is `0x0001` with payload bytes preserved.
- `advise-tagid-11223344-item-A.bin`
- Bytes: `0A 00 10 80 44 33 22 11 01 41 00 A5`
- Assumption validated: ADVISE message type bytes are `10 80` (little-endian `0x8010`) and item encoding is UTF-16LE length-prefixed.
- `update-binary-tag-1234-true.bin`
- Bytes: `0D 00 09 00 34 12 00 00 0A 00 C0 00 01 01 A5`
- Assumption validated: UPDATE type bytes are `09 00` and binary value decode path maps to `true`.
## Notes
- Keep fixture count modest and focused on wire-level assumptions that are easy to regress.
- If a fixture changes, update corresponding tests and document why the protocol assumption changed.

View File

@@ -0,0 +1,194 @@
using SuiteLink.Client.Internal;
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Internal;
public sealed class SuiteLinkSessionTests
{
[Fact]
public void NewSession_StartsDisconnected()
{
var session = new SuiteLinkSession();
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
}
[Fact]
public void RegisterSubscription_TracksForwardAndReverseMappings()
{
var session = new SuiteLinkSession();
session.RegisterSubscription("Pump001.Run", 0x1234, _ => { });
Assert.True(session.TryGetTagId("Pump001.Run", out var tagId));
Assert.Equal(0x1234u, tagId);
Assert.True(session.TryGetItemName(0x1234, out var itemName));
Assert.Equal("Pump001.Run", itemName);
}
[Fact]
public void TryDispatchUpdate_KnownTag_InvokesRegisteredCallback()
{
var session = new SuiteLinkSession();
SuiteLinkTagUpdate? callbackUpdate = null;
session.RegisterSubscription("Pump001.Run", 0x1234, update => callbackUpdate = update);
var decoded = new DecodedUpdate(
TagId: 0x1234,
Quality: 0x00C0,
ElapsedMilliseconds: 10,
Value: SuiteLinkValue.FromBoolean(true));
var receivedAtUtc = new DateTimeOffset(2026, 03, 16, 18, 00, 00, TimeSpan.Zero);
var dispatched = session.TryDispatchUpdate(decoded, receivedAtUtc, out var dispatchedUpdate);
Assert.True(dispatched);
Assert.NotNull(dispatchedUpdate);
Assert.Equal("Pump001.Run", dispatchedUpdate.ItemName);
Assert.Equal(0x1234u, dispatchedUpdate.TagId);
Assert.Equal(0x00C0, dispatchedUpdate.Quality);
Assert.Equal(10, dispatchedUpdate.ElapsedMilliseconds);
Assert.Equal(receivedAtUtc, dispatchedUpdate.ReceivedAtUtc);
Assert.Equal(dispatchedUpdate, callbackUpdate);
}
[Fact]
public void TryDispatchUpdate_UnknownTag_ReturnsFalseAndDoesNotInvokeCallback()
{
var session = new SuiteLinkSession();
var callbackCount = 0;
session.RegisterSubscription("Pump001.Run", 0x1234, _ => callbackCount++);
var decoded = new DecodedUpdate(
TagId: 0x9999,
Quality: 0x00C0,
ElapsedMilliseconds: 5,
Value: SuiteLinkValue.FromInt32(42));
var dispatched = session.TryDispatchUpdate(decoded, DateTimeOffset.UtcNow, out var dispatchedUpdate);
Assert.False(dispatched);
Assert.Null(dispatchedUpdate);
Assert.Equal(0, callbackCount);
}
[Fact]
public void UnregisterByItemName_RemovesMappingsAndCallback()
{
var session = new SuiteLinkSession();
var callbackCount = 0;
session.RegisterSubscription("Pump001.Run", 0x1234, _ => callbackCount++);
Assert.True(session.TryUnregisterByItemName("Pump001.Run", out var removedTagId));
Assert.Equal(0x1234u, removedTagId);
Assert.False(session.TryGetTagId("Pump001.Run", out _));
Assert.False(session.TryGetItemName(0x1234, out _));
var decoded = new DecodedUpdate(
TagId: 0x1234,
Quality: 0x00C0,
ElapsedMilliseconds: 1,
Value: SuiteLinkValue.FromBoolean(true));
var dispatched = session.TryDispatchUpdate(decoded, DateTimeOffset.UtcNow, out _);
Assert.False(dispatched);
Assert.Equal(0, callbackCount);
}
[Fact]
public void RegisterSubscription_SameItemName_ReplacesOldTagAndCallback()
{
var session = new SuiteLinkSession();
var oldCount = 0;
var newCount = 0;
session.RegisterSubscription("Pump001.Run", 0x1000, _ => oldCount++);
session.RegisterSubscription("Pump001.Run", 0x2000, _ => newCount++);
Assert.False(session.TryGetItemName(0x1000, out _));
Assert.True(session.TryGetTagId("Pump001.Run", out var currentTagId));
Assert.Equal(0x2000u, currentTagId);
var oldDecoded = new DecodedUpdate(0x1000, 0x00C0, 1, SuiteLinkValue.FromBoolean(true));
var newDecoded = new DecodedUpdate(0x2000, 0x00C0, 1, SuiteLinkValue.FromBoolean(true));
Assert.False(session.TryDispatchUpdate(oldDecoded, DateTimeOffset.UtcNow, out _));
Assert.True(session.TryDispatchUpdate(newDecoded, DateTimeOffset.UtcNow, out _));
Assert.Equal(0, oldCount);
Assert.Equal(1, newCount);
}
[Fact]
public void RegisterSubscription_SameTagId_ReplacesOldItemAndCallback()
{
var session = new SuiteLinkSession();
var oldCount = 0;
var newCount = 0;
session.RegisterSubscription("Pump001.Run", 0x1234, _ => oldCount++);
session.RegisterSubscription("Pump002.Run", 0x1234, _ => newCount++);
Assert.False(session.TryGetTagId("Pump001.Run", out _));
Assert.True(session.TryGetTagId("Pump002.Run", out var replacementTagId));
Assert.Equal(0x1234u, replacementTagId);
var decoded = new DecodedUpdate(0x1234, 0x00C0, 1, SuiteLinkValue.FromBoolean(true));
Assert.True(session.TryDispatchUpdate(decoded, DateTimeOffset.UtcNow, out var dispatchedUpdate));
Assert.NotNull(dispatchedUpdate);
Assert.Equal("Pump002.Run", dispatchedUpdate.ItemName);
Assert.Equal(0, oldCount);
Assert.Equal(1, newCount);
}
[Fact]
public void TryDispatchUpdate_CallbackThrows_IsCaughtAndReported()
{
var session = new SuiteLinkSession();
session.RegisterSubscription("Pump001.Run", 0x1234, _ => throw new InvalidOperationException("callback failure"));
var decoded = new DecodedUpdate(
TagId: 0x1234,
Quality: 0x00C0,
ElapsedMilliseconds: 5,
Value: SuiteLinkValue.FromInt32(42));
var dispatched = session.TryDispatchUpdate(
decoded,
DateTimeOffset.UtcNow,
out var dispatchedUpdate,
out var callbackException);
Assert.False(dispatched);
Assert.NotNull(dispatchedUpdate);
Assert.NotNull(callbackException);
Assert.Equal("callback failure", callbackException.Message);
}
[Fact]
public void SetState_InvalidTransition_ThrowsInvalidOperationException()
{
var session = new SuiteLinkSession();
var ex = Assert.Throws<InvalidOperationException>(() => session.SetState(SuiteLinkSessionState.SessionConnected));
Assert.Contains("Invalid state transition", ex.Message);
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
}
[Fact]
public void TryTransitionState_EnforcesExpectedCurrentStateAtomically()
{
var session = new SuiteLinkSession();
Assert.True(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.TcpConnected));
Assert.Equal(SuiteLinkSessionState.TcpConnected, session.State);
Assert.False(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.HandshakeComplete));
Assert.Equal(SuiteLinkSessionState.TcpConnected, session.State);
}
}

View File

@@ -1,5 +1,6 @@
using System.Buffers.Binary;
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Tests.Fixtures;
namespace SuiteLink.Client.Tests.Protocol;
@@ -33,9 +34,7 @@ public sealed class SuiteLinkHandshakeCodecTests
[Fact]
public void ParseNormalHandshakeAck_WithNormalAckFrame_ReturnsAckData()
{
// Fixed vector for normal ACK assumption:
// remaining=0x0006, type=0x0001, payload=0xA1B2C3, marker=0xA5.
byte[] frame = [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
var frame = FixtureBytes.Read("handshake-ack-normal.bin");
var ack = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(frame);

View File

@@ -1,4 +1,5 @@
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Tests.Fixtures;
namespace SuiteLink.Client.Tests.Protocol;
@@ -9,7 +10,7 @@ public sealed class SuiteLinkSubscriptionCodecTests
{
var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise(0x11223344, "A");
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
byte[] expected = [0x0A, 0x00, 0x10, 0x80, 0x44, 0x33, 0x22, 0x11, 0x01, 0x41, 0x00, 0xA5];
var expected = FixtureBytes.Read("advise-tagid-11223344-item-A.bin");
Assert.Equal(expected, bytes);
Assert.Equal(0x10, bytes[2]);

View File

@@ -1,5 +1,6 @@
using System.Text;
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Tests.Fixtures;
namespace SuiteLink.Client.Tests.Protocol;
@@ -8,16 +9,7 @@ public sealed class SuiteLinkUpdateCodecTests
[Fact]
public void DecodeUpdate_DecodesBinaryValue()
{
byte[] frame =
[
0x0D, 0x00, 0x09, 0x00,
0x34, 0x12, 0x00, 0x00,
0x0A, 0x00,
0xC0, 0x00,
0x01,
0x01,
0xA5
];
var frame = FixtureBytes.Read("update-binary-tag-1234-true.bin");
Assert.Equal(0x09, frame[2]);
Assert.Equal(0x00, frame[3]);

View File

@@ -0,0 +1,66 @@
using SuiteLink.Client.Protocol;
namespace SuiteLink.Client.Tests.Protocol;
public sealed class SuiteLinkWriteCodecTests
{
[Fact]
public void EncodeWrite_BooleanValue_WritesExpectedGoldenVector()
{
var bytes = SuiteLinkWriteCodec.Encode(0x12345678, SuiteLinkValue.FromBoolean(true));
byte[] expected = [0x09, 0x00, 0x0B, 0x08, 0x78, 0x56, 0x34, 0x12, 0x01, 0x01, 0xA5];
Assert.Equal(expected, bytes);
Assert.Equal(0x0B, bytes[2]);
Assert.Equal(0x08, bytes[3]);
}
[Fact]
public void EncodeWrite_Int32Value_WritesExpectedPayload()
{
var bytes = SuiteLinkWriteCodec.Encode(0x89ABCDEF, SuiteLinkValue.FromInt32(42));
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
Assert.Equal(0x0B, bytes[2]);
Assert.Equal(0x08, bytes[3]);
Assert.Equal(SuiteLinkWriteCodec.PokeMessageType, frame.MessageType);
Assert.Equal(0x89ABCDEFu, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span));
Assert.Equal((byte)SuiteLinkWireValueType.Integer, frame.Payload.Span[4]);
Assert.Equal(42, SuiteLinkEncoding.ReadInt32LittleEndian(frame.Payload.Span[5..]));
}
[Fact]
public void EncodeWrite_Float32Value_WritesExpectedPayload()
{
var bytes = SuiteLinkWriteCodec.Encode(0x00000007, SuiteLinkValue.FromFloat32(12.5f));
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
Assert.Equal(0x0B, bytes[2]);
Assert.Equal(0x08, bytes[3]);
Assert.Equal((byte)SuiteLinkWireValueType.Real, frame.Payload.Span[4]);
Assert.Equal(12.5f, SuiteLinkEncoding.ReadSingleLittleEndian(frame.Payload.Span[5..]));
}
[Fact]
public void EncodeWrite_StringValue_WritesExpectedPayload()
{
var bytes = SuiteLinkWriteCodec.Encode(0x00000008, SuiteLinkValue.FromString("OK"));
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
Assert.Equal(0x0B, bytes[2]);
Assert.Equal(0x08, bytes[3]);
Assert.Equal((byte)SuiteLinkWireValueType.Message, frame.Payload.Span[4]);
Assert.Equal((ushort)2, SuiteLinkEncoding.ReadUInt16LittleEndian(frame.Payload.Span[5..]));
Assert.Equal((byte)'O', frame.Payload.Span[7]);
Assert.Equal((byte)'K', frame.Payload.Span[8]);
}
[Fact]
public void EncodeWrite_NoneValue_ThrowsNotSupportedException()
{
var ex = Assert.Throws<NotSupportedException>(
() => SuiteLinkWriteCodec.Encode(0x00000001, default));
Assert.Contains("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -22,4 +22,8 @@
<ProjectReference Include="..\..\src\SuiteLink.Client\SuiteLink.Client.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<None Include="Fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,209 @@
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Transport;
namespace SuiteLink.Client.Tests;
public sealed class SuiteLinkClientConnectionTests
{
[Fact]
public async Task ConnectAsync_SendsHandshakeThenConnect_ButDoesNotReportReadyYet()
{
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
var transport = new FakeTransport([handshakeAckFrame[..4], handshakeAckFrame[4..]]);
var client = new SuiteLinkClient(transport);
var options = CreateOptions();
await client.ConnectAsync(options);
Assert.False(client.IsConnected);
Assert.Equal(2, transport.SentBuffers.Count);
Assert.Equal(
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
options.Application,
options.ClientNode,
options.UserName),
transport.SentBuffers[0]);
Assert.Equal(SuiteLinkConnectCodec.Encode(options), transport.SentBuffers[1]);
Assert.Equal(1, transport.ConnectCallCount);
Assert.Equal(options.Host, transport.ConnectedHost);
Assert.Equal(options.Port, transport.ConnectedPort);
}
[Fact]
public async Task DisconnectAsync_UsesSingleUseClientSemantics_AndDoesNotDisposeExternalTransport()
{
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
var transport = new FakeTransport([handshakeAckFrame]);
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
await client.DisconnectAsync();
Assert.False(client.IsConnected);
Assert.Equal(0, transport.DisposeCallCount);
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.ConnectAsync(CreateOptions()));
}
[Fact]
public async Task DisposeAsync_WithOwnedTransport_DisposesUnderlyingTransport()
{
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
var transport = new FakeTransport([handshakeAckFrame]);
var client = new SuiteLinkClient(transport, ownsTransport: true);
await client.ConnectAsync(CreateOptions());
await client.DisposeAsync();
Assert.False(client.IsConnected);
Assert.Equal(1, transport.DisposeCallCount);
}
[Fact]
public async Task ConnectAsync_MalformedHandshakeAck_ThrowsAndFaultsClient()
{
var malformedAck = new byte[] { 0x03, 0x00, 0x02, 0x00, 0xA5 };
var transport = new FakeTransport([malformedAck]);
var client = new SuiteLinkClient(transport);
await Assert.ThrowsAsync<FormatException>(() => client.ConnectAsync(CreateOptions()));
Assert.False(client.IsConnected);
Assert.Equal(1, transport.ConnectCallCount);
Assert.Single(transport.SentBuffers); // handshake only
}
[Fact]
public async Task ConnectAsync_RemoteEofDuringHandshake_ThrowsAndFaultsClient()
{
var transport = new FakeTransport(receiveChunks: []);
var client = new SuiteLinkClient(transport);
await Assert.ThrowsAsync<IOException>(() => client.ConnectAsync(CreateOptions()));
Assert.False(client.IsConnected);
Assert.Equal(1, transport.ConnectCallCount);
Assert.Single(transport.SentBuffers); // handshake only
}
[Fact]
public async Task ConnectAsync_RepeatedWhilePending_DoesNotSendDuplicateStartupFrames()
{
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
var transport = new FakeTransport([handshakeAckFrame]);
var client = new SuiteLinkClient(transport);
var options = CreateOptions();
await client.ConnectAsync(options);
await client.ConnectAsync(options);
Assert.Equal(1, transport.ConnectCallCount);
Assert.Equal(2, transport.SentBuffers.Count);
}
[Fact]
public async Task ConnectAsync_ConcurrentCalls_AreSerializedAndDoNotDuplicateStartupFrames()
{
var handshakeAckFrame = new byte[] { 0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5 };
var receiveGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var transport = new FakeTransport([handshakeAckFrame])
{
ReceiveGate = receiveGate.Task
};
var client = new SuiteLinkClient(transport);
var options = CreateOptions();
var connectTask1 = client.ConnectAsync(options);
while (transport.SentBuffers.Count == 0)
{
await Task.Delay(10);
}
var connectTask2 = client.ConnectAsync(options);
receiveGate.SetResult();
await Task.WhenAll(connectTask1, connectTask2);
Assert.Equal(1, transport.ConnectCallCount);
Assert.Equal(2, transport.SentBuffers.Count);
}
private static SuiteLinkConnectionOptions CreateOptions()
{
return new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server",
timezone: "UTC",
port: 5413);
}
private sealed class FakeTransport : ISuiteLinkTransport
{
private readonly object _syncRoot = new();
private readonly Queue<byte[]> _receiveChunks;
private bool _disposed;
public FakeTransport(IEnumerable<byte[]> receiveChunks)
{
_receiveChunks = new Queue<byte[]>(receiveChunks);
}
public Task? ReceiveGate { get; init; }
public string ConnectedHost { get; private set; } = string.Empty;
public int ConnectedPort { get; private set; }
public int ConnectCallCount { get; private set; }
public int DisposeCallCount { get; private set; }
public bool IsConnected => ConnectCallCount > 0 && !_disposed;
public List<byte[]> SentBuffers { get; } = [];
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
{
ConnectCallCount++;
ConnectedHost = host;
ConnectedPort = port;
return ValueTask.CompletedTask;
}
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
SentBuffers.Add(buffer.ToArray());
}
return ValueTask.CompletedTask;
}
public async ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (ReceiveGate is not null)
{
await ReceiveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
}
byte[]? next;
lock (_syncRoot)
{
if (_receiveChunks.Count == 0)
{
return 0;
}
next = _receiveChunks.Dequeue();
}
next.CopyTo(buffer);
return next.Length;
}
public ValueTask DisposeAsync()
{
_disposed = true;
DisposeCallCount++;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,336 @@
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Transport;
namespace SuiteLink.Client.Tests;
public sealed class SuiteLinkClientSubscriptionTests
{
[Fact]
public async Task SubscribeAsync_SendsAdvise_AndReturnsSubscriptionHandle()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var handle = await client.SubscribeAsync("Pump001.Run", _ => { });
Assert.True(client.IsConnected);
Assert.Equal("Pump001.Run", handle.ItemName);
Assert.Equal(1u, handle.TagId);
Assert.Equal(3, transport.SentBuffers.Count);
var adviseFrame = SuiteLinkFrameReader.ParseFrame(transport.SentBuffers[2]);
Assert.Equal(SuiteLinkSubscriptionCodec.AdviseMessageType, adviseFrame.MessageType);
Assert.Equal(1u, SuiteLinkEncoding.ReadUInt32LittleEndian(adviseFrame.Payload.Span[..4]));
}
[Fact]
public async Task ProcessIncomingAsync_UpdateFrame_DispatchesToSubscriptionCallback()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
SuiteLinkTagUpdate? callbackUpdate = null;
_ = await client.SubscribeAsync("Pump001.Run", update => callbackUpdate = update);
await client.ProcessIncomingAsync();
Assert.NotNull(callbackUpdate);
Assert.Equal("Pump001.Run", callbackUpdate.ItemName);
Assert.True(callbackUpdate.Value.TryGetBoolean(out var value));
Assert.True(value);
}
[Fact]
public async Task ReadAsync_ReturnsFirstMatchingUpdate_UsingTemporarySubscription()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.EnqueueReceive(BuildIntegerUpdateFrame(1, 42));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var update = await client.ReadAsync("Pump001.Speed", TimeSpan.FromSeconds(2));
Assert.Equal("Pump001.Speed", update.ItemName);
Assert.True(update.Value.TryGetInt32(out var value));
Assert.Equal(42, value);
Assert.Equal(4, transport.SentBuffers.Count);
var unadviseFrame = SuiteLinkFrameReader.ParseFrame(transport.SentBuffers[3]);
Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, unadviseFrame.MessageType);
Assert.Equal(1u, SuiteLinkEncoding.ReadUInt32LittleEndian(unadviseFrame.Payload.Span));
}
[Fact]
public async Task SubscriptionHandleDisposeAsync_SendsUnadvise_AndStopsFurtherDispatch()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var callbackCount = 0;
var handle = await client.SubscribeAsync("Pump001.Run", _ => callbackCount++);
await handle.DisposeAsync();
await client.ProcessIncomingAsync();
Assert.Equal(4, transport.SentBuffers.Count);
var unadviseFrame = SuiteLinkFrameReader.ParseFrame(transport.SentBuffers[3]);
Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, unadviseFrame.MessageType);
Assert.Equal(1u, SuiteLinkEncoding.ReadUInt32LittleEndian(unadviseFrame.Payload.Span));
Assert.Equal(0, callbackCount);
}
[Fact]
public async Task ProcessIncomingAsync_CallbackDisposesSubscription_DoesNotDeadlock()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
SubscriptionHandle? handle = null;
var callbackCompleted = false;
handle = await client.SubscribeAsync(
"Pump001.Run",
_ =>
{
handle!.DisposeAsync().AsTask().GetAwaiter().GetResult();
callbackCompleted = true;
});
await client.ProcessIncomingAsync().WaitAsync(TimeSpan.FromSeconds(1));
Assert.True(callbackCompleted);
Assert.Equal(4, transport.SentBuffers.Count);
var unadviseFrame = SuiteLinkFrameReader.ParseFrame(transport.SentBuffers[3]);
Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, unadviseFrame.MessageType);
}
[Fact]
public async Task ProcessIncomingAsync_CallbackCanInvokeNestedProcessing_WithoutDeadlock()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, true));
transport.EnqueueReceive(BuildBooleanUpdateFrame(1, false));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var values = new List<bool>();
var nestedCalled = false;
_ = await client.SubscribeAsync(
"Pump001.Run",
update =>
{
if (update.Value.TryGetBoolean(out var value))
{
values.Add(value);
}
if (!nestedCalled)
{
nestedCalled = true;
client.ProcessIncomingAsync().GetAwaiter().GetResult();
}
});
await client.ProcessIncomingAsync().WaitAsync(TimeSpan.FromSeconds(1));
Assert.Equal(2, values.Count);
Assert.True(values[0]);
Assert.False(values[1]);
}
[Fact]
public async Task ReadAsync_PreservesPrimaryReceiveFailure_WhenCleanupUnadviseFails()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
transport.SendFailureFactory = frameBytes =>
{
var span = frameBytes.Span;
var isUnadviseFrame = span.Length >= 4 &&
span[2] == 0x04 &&
span[3] == 0x80;
return isUnadviseFrame
? new IOException("Synthetic unadvise send failure.")
: null;
};
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var ex = await Assert.ThrowsAsync<IOException>(
() => client.ReadAsync("Pump001.Speed", TimeSpan.FromSeconds(2)));
Assert.Contains("Remote endpoint closed", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubscribeAsync_RejectsMultiItemAdviseAck_AsUnsupportedCorrelationPolicy()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1, 2));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
await Assert.ThrowsAsync<FormatException>(
() => client.SubscribeAsync("Pump001.Run", _ => { }));
}
[Fact]
public async Task SubscribeAsync_RejectsMismatchedAdviseAckTagId()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(0x1234));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
await Assert.ThrowsAsync<FormatException>(
() => client.SubscribeAsync("Pump001.Run", _ => { }));
}
private static SuiteLinkConnectionOptions CreateOptions()
{
return new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server",
timezone: "UTC",
port: 5413);
}
private static byte[] BuildHandshakeAckFrame()
{
return [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
}
private static byte[] BuildAdviseAckFrame(params uint[] tagIds)
{
var payload = new byte[Math.Max(1, tagIds.Length) * 5];
var ids = tagIds.Length == 0 ? [0u] : tagIds;
var offset = 0;
foreach (var tagId in ids)
{
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(offset, 4), tagId);
payload[offset + 4] = 0x00;
offset += 5;
}
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkSubscriptionCodec.AdviseAckMessageType, payload);
}
private static byte[] BuildBooleanUpdateFrame(uint tagId, bool value)
{
var payload = new byte[10];
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(0, 4), tagId);
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(4, 2), 1);
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(6, 2), 0x00C0);
payload[8] = (byte)SuiteLinkWireValueType.Binary;
payload[9] = value ? (byte)1 : (byte)0;
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkUpdateCodec.UpdateMessageType, payload);
}
private static byte[] BuildIntegerUpdateFrame(uint tagId, int value)
{
var payload = new byte[13];
SuiteLinkEncoding.WriteUInt32LittleEndian(payload.AsSpan(0, 4), tagId);
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(4, 2), 1);
SuiteLinkEncoding.WriteUInt16LittleEndian(payload.AsSpan(6, 2), 0x00C0);
payload[8] = (byte)SuiteLinkWireValueType.Integer;
SuiteLinkEncoding.WriteInt32LittleEndian(payload.AsSpan(9, 4), value);
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkUpdateCodec.UpdateMessageType, payload);
}
private sealed class FakeTransport : ISuiteLinkTransport
{
private readonly Queue<byte[]> _receiveChunks = [];
private readonly object _syncRoot = new();
public bool IsConnected { get; private set; }
public List<byte[]> SentBuffers { get; } = [];
public Func<ReadOnlyMemory<byte>, Exception?>? SendFailureFactory { get; set; }
public void EnqueueReceive(byte[] bytes)
{
lock (_syncRoot)
{
_receiveChunks.Enqueue(bytes);
}
}
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
{
IsConnected = true;
return ValueTask.CompletedTask;
}
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
SentBuffers.Add(buffer.ToArray());
}
var sendFailure = SendFailureFactory?.Invoke(buffer);
if (sendFailure is not null)
{
throw sendFailure;
}
return ValueTask.CompletedTask;
}
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
if (_receiveChunks.Count == 0)
{
return ValueTask.FromResult(0);
}
var next = _receiveChunks.Dequeue();
next.CopyTo(buffer);
return ValueTask.FromResult(next.Length);
}
}
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,126 @@
using SuiteLink.Client.Protocol;
using SuiteLink.Client.Transport;
namespace SuiteLink.Client.Tests;
public sealed class SuiteLinkClientWriteTests
{
[Fact]
public async Task WriteAsync_SendsPokeFrame_ForSubscribedTag()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
transport.EnqueueReceive(BuildAdviseAckFrame(1));
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
await client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true));
Assert.Equal(4, transport.SentBuffers.Count);
var pokeFrame = SuiteLinkFrameReader.ParseFrame(transport.SentBuffers[3]);
Assert.Equal(SuiteLinkWriteCodec.PokeMessageType, pokeFrame.MessageType);
Assert.Equal(1u, SuiteLinkEncoding.ReadUInt32LittleEndian(pokeFrame.Payload.Span[..4]));
Assert.Equal((byte)SuiteLinkWireValueType.Binary, pokeFrame.Payload.Span[4]);
Assert.Equal((byte)1, pokeFrame.Payload.Span[5]);
}
[Fact]
public async Task WriteAsync_UnknownTag_ThrowsInvalidOperationException()
{
var transport = new FakeTransport();
transport.EnqueueReceive(BuildHandshakeAckFrame());
var client = new SuiteLinkClient(transport);
await client.ConnectAsync(CreateOptions());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.WriteAsync("Pump001.Unknown", SuiteLinkValue.FromInt32(42)));
Assert.Contains("not subscribed", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(2, transport.SentBuffers.Count);
}
private static SuiteLinkConnectionOptions CreateOptions()
{
return new SuiteLinkConnectionOptions(
host: "127.0.0.1",
application: "App",
topic: "Topic",
clientName: "Client",
clientNode: "Node",
userName: "User",
serverNode: "Server",
timezone: "UTC",
port: 5413);
}
private static byte[] BuildHandshakeAckFrame()
{
return [0x06, 0x00, 0x01, 0x00, 0xA1, 0xB2, 0xC3, 0xA5];
}
private static byte[] BuildAdviseAckFrame(uint tagId)
{
Span<byte> payload = stackalloc byte[5];
SuiteLinkEncoding.WriteUInt32LittleEndian(payload[..4], tagId);
payload[4] = 0x00;
return SuiteLinkFrameWriter.WriteFrame(SuiteLinkSubscriptionCodec.AdviseAckMessageType, payload);
}
private sealed class FakeTransport : ISuiteLinkTransport
{
private readonly Queue<byte[]> _receiveChunks = [];
private readonly object _syncRoot = new();
public bool IsConnected { get; private set; }
public List<byte[]> SentBuffers { get; } = [];
public void EnqueueReceive(byte[] bytes)
{
lock (_syncRoot)
{
_receiveChunks.Enqueue(bytes);
}
}
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
{
IsConnected = true;
return ValueTask.CompletedTask;
}
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
SentBuffers.Add(buffer.ToArray());
}
return ValueTask.CompletedTask;
}
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
lock (_syncRoot)
{
if (_receiveChunks.Count == 0)
{
return new ValueTask<int>(0);
}
var next = _receiveChunks.Dequeue();
next.CopyTo(buffer);
return new ValueTask<int>(next.Length);
}
}
public ValueTask DisposeAsync()
{
IsConnected = false;
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,179 @@
using SuiteLink.Client.Transport;
using System.Net;
using System.Net.Sockets;
namespace SuiteLink.Client.Tests.Transport;
public sealed class SuiteLinkTcpTransportTests
{
[Fact]
public async Task SendAsync_WithInjectedStream_WritesBytes()
{
using var stream = new MemoryStream();
await using var transport = new SuiteLinkTcpTransport(stream);
byte[] payload = [0x01, 0x02, 0x03];
await transport.SendAsync(payload, CancellationToken.None);
Assert.Equal(payload, stream.ToArray());
}
[Fact]
public async Task ReceiveAsync_WithInjectedStream_ReadsBytes()
{
using var stream = new MemoryStream([0x10, 0x20, 0x30]);
await using var transport = new SuiteLinkTcpTransport(stream);
byte[] buffer = new byte[2];
var bytesRead = await transport.ReceiveAsync(buffer, CancellationToken.None);
Assert.Equal(2, bytesRead);
Assert.Equal(0x10, buffer[0]);
Assert.Equal(0x20, buffer[1]);
}
[Fact]
public async Task SendAsync_WithoutConnection_ThrowsInvalidOperationException()
{
await using var transport = new SuiteLinkTcpTransport();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => transport.SendAsync(new byte[] { 0xAA }, CancellationToken.None).AsTask());
Assert.Contains("connected", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ReceiveAsync_WithPartialReadStream_ReturnsPartialReadsAndEof()
{
using var stream = new PartialReadStream([0x10, 0x20, 0x30], maxChunkSize: 1);
await using var transport = new SuiteLinkTcpTransport(stream);
byte[] buffer = new byte[3];
var read1 = await transport.ReceiveAsync(buffer, CancellationToken.None);
var read2 = await transport.ReceiveAsync(buffer, CancellationToken.None);
var read3 = await transport.ReceiveAsync(buffer, CancellationToken.None);
var read4 = await transport.ReceiveAsync(buffer, CancellationToken.None);
Assert.Equal(1, read1);
Assert.Equal(1, read2);
Assert.Equal(1, read3);
Assert.Equal(0, read4);
}
[Fact]
public async Task DisposeAsync_AfterDisposal_SendAndReceiveThrowObjectDisposedException()
{
using var stream = new MemoryStream([0x01, 0x02, 0x03]);
var transport = new SuiteLinkTcpTransport(stream);
await transport.DisposeAsync();
await Assert.ThrowsAsync<ObjectDisposedException>(
() => transport.SendAsync(new byte[] { 0xAA }, CancellationToken.None).AsTask());
await Assert.ThrowsAsync<ObjectDisposedException>(
() => transport.ReceiveAsync(new byte[1], CancellationToken.None).AsTask());
}
[Fact]
public async Task DisposeAsync_LeaveOpenTrue_DoesNotDisposeInjectedStream()
{
var stream = new TrackingStream();
await using (var transport = new SuiteLinkTcpTransport(stream, leaveOpen: true))
{
await transport.DisposeAsync();
}
Assert.False(stream.WasDisposed);
}
[Fact]
public async Task ConnectAsync_ConcurrentCalls_CreateSingleConnection()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
await using var transport = new SuiteLinkTcpTransport();
Task[] connectTasks =
[
transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(),
transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(),
transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask(),
transport.ConnectAsync(endpoint.Address.ToString(), endpoint.Port).AsTask()
];
await Task.WhenAll(connectTasks);
using var accepted1 = await listener.AcceptTcpClientAsync();
using var secondAcceptCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await listener.AcceptTcpClientAsync(secondAcceptCts.Token));
}
private sealed class PartialReadStream : Stream
{
private readonly MemoryStream _inner;
private readonly int _maxChunkSize;
public PartialReadStream(byte[] bytes, int maxChunkSize)
{
_inner = new MemoryStream(bytes);
_maxChunkSize = maxChunkSize;
}
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _inner.Length;
public override long Position
{
get => _inner.Position;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
return _inner.Read(buffer, offset, Math.Min(count, _maxChunkSize));
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
return _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maxChunkSize)], cancellationToken);
}
public override void Flush()
{
throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
private sealed class TrackingStream : MemoryStream
{
public bool WasDisposed { get; private set; }
protected override void Dispose(bool disposing)
{
WasDisposed = true;
base.Dispose(disposing);
}
}
}