245 lines
8.5 KiB
C#
245 lines
8.5 KiB
C#
using SuiteLink.Client.Protocol;
|
|
using SuiteLink.Client.Internal;
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_DuringReconnect_ThrowsClearException()
|
|
{
|
|
var transport = new RuntimeDisconnectFakeTransport()
|
|
.WithFrame(BuildHandshakeAckFrame())
|
|
.WithFrame(BuildAdviseAckFrame(1));
|
|
var client = new SuiteLinkClient(
|
|
transport,
|
|
ownsTransport: false,
|
|
delayAsync: static async (delay, cancellationToken) =>
|
|
{
|
|
await Task.Yield();
|
|
if (delay > TimeSpan.Zero)
|
|
{
|
|
await Task.Delay(delay, cancellationToken);
|
|
}
|
|
},
|
|
reconnectAttemptAsync: static _ => ValueTask.FromResult(false));
|
|
|
|
await client.ConnectAsync(CreateOptions());
|
|
_ = await client.SubscribeAsync("Pump001.Run", _ => { });
|
|
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
|
while (DateTime.UtcNow < deadline && client.DebugState != SuiteLinkSessionState.Reconnecting)
|
|
{
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true)));
|
|
|
|
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
await client.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WriteAsync_DuringReconnect_ThrowsBeforeWaitingOnOperationGate()
|
|
{
|
|
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", _ => { });
|
|
|
|
var releaseGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var acquiredGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var holdGateTask = client.DebugHoldOperationGateAsync(releaseGate.Task, acquiredGate);
|
|
_ = await acquiredGate.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
|
|
|
var sessionField = typeof(SuiteLinkClient)
|
|
.GetField("_session", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
|
var session = (SuiteLinkSession)sessionField.GetValue(client)!;
|
|
Assert.True(session.TryTransitionState(SuiteLinkSessionState.Subscribed, SuiteLinkSessionState.Reconnecting));
|
|
|
|
var writeTask = client.WriteAsync("Pump001.Run", SuiteLinkValue.FromBoolean(true));
|
|
var completed = await Task.WhenAny(writeTask, Task.Delay(200));
|
|
|
|
releaseGate.TrySetResult(true);
|
|
await holdGateTask.WaitAsync(TimeSpan.FromSeconds(2));
|
|
|
|
Assert.Same(writeTask, completed);
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => writeTask);
|
|
Assert.Contains("reconnecting", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
private sealed class RuntimeDisconnectFakeTransport : ISuiteLinkTransport
|
|
{
|
|
private readonly Queue<byte[]> _receiveChunks = [];
|
|
private readonly object _syncRoot = new();
|
|
|
|
public bool IsConnected { get; private set; }
|
|
|
|
public RuntimeDisconnectFakeTransport WithFrame(byte[] bytes)
|
|
{
|
|
lock (_syncRoot)
|
|
{
|
|
_receiveChunks.Enqueue(bytes);
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)
|
|
{
|
|
IsConnected = true;
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask SendAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|