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( () => 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 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 _receiveChunks = []; private readonly object _syncRoot = new(); public bool IsConnected { get; private set; } public List 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 buffer, CancellationToken cancellationToken = default) { lock (_syncRoot) { SentBuffers.Add(buffer.ToArray()); } return ValueTask.CompletedTask; } public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default) { lock (_syncRoot) { if (_receiveChunks.Count == 0) { return new ValueTask(0); } var next = _receiveChunks.Dequeue(); next.CopyTo(buffer); return new ValueTask(next.Length); } } public ValueTask DisposeAsync() { IsConnected = false; return ValueTask.CompletedTask; } } }