feat: bootstrap suitelink tag client codecs
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkConnectCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncodeConnect_WritesConnectMessageTypeAndEndMarker()
|
||||
{
|
||||
var options = new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC");
|
||||
|
||||
var bytes = SuiteLinkConnectCodec.Encode(options);
|
||||
|
||||
Assert.Equal(0x80, bytes[2]);
|
||||
Assert.Equal(0x01, bytes[3]);
|
||||
Assert.Equal(0xA5, bytes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeConnect_WritesExpectedFieldOrderReservedSegmentsAndTimezoneStrings()
|
||||
{
|
||||
var options = new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server",
|
||||
timezone: "UTC");
|
||||
|
||||
var bytes = SuiteLinkConnectCodec.Encode(options);
|
||||
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
|
||||
var payload = frame.Payload.Span;
|
||||
var index = 0;
|
||||
|
||||
var app = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App");
|
||||
Assert.True(payload[index..].StartsWith(app));
|
||||
index += app.Length;
|
||||
|
||||
var topic = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic");
|
||||
Assert.True(payload[index..].StartsWith(topic));
|
||||
index += topic.Length;
|
||||
|
||||
Assert.True(payload[index..(index + 3)].ToArray().All(static b => b == 0x00));
|
||||
index += 3;
|
||||
|
||||
var client = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client");
|
||||
Assert.True(payload[index..].StartsWith(client));
|
||||
index += client.Length;
|
||||
|
||||
var clientNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node");
|
||||
Assert.True(payload[index..].StartsWith(clientNode));
|
||||
index += clientNode.Length;
|
||||
|
||||
var user = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User");
|
||||
Assert.True(payload[index..].StartsWith(user));
|
||||
index += user.Length;
|
||||
|
||||
var serverNode = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server");
|
||||
Assert.True(payload[index..].StartsWith(serverNode));
|
||||
index += serverNode.Length;
|
||||
|
||||
Assert.True(payload[index..(index + 20)].ToArray().All(static b => b == 0x00));
|
||||
index += 20;
|
||||
|
||||
var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes);
|
||||
index += timezone1ConsumedBytes;
|
||||
Assert.Equal("UTC", timezone1);
|
||||
|
||||
Assert.True(payload[index..(index + 38)].ToArray().All(static b => b == 0x00));
|
||||
index += 38;
|
||||
|
||||
var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone2ConsumedBytes);
|
||||
index += timezone2ConsumedBytes;
|
||||
Assert.Equal("UTC", timezone2);
|
||||
Assert.Equal(timezone1, timezone2);
|
||||
|
||||
Assert.Equal(payload.Length, index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeConnect_WhenTimezoneNotProvided_UsesUtcDefault()
|
||||
{
|
||||
var options = new SuiteLinkConnectionOptions(
|
||||
host: "127.0.0.1",
|
||||
application: "App",
|
||||
topic: "Topic",
|
||||
clientName: "Client",
|
||||
clientNode: "Node",
|
||||
userName: "User",
|
||||
serverNode: "Server");
|
||||
|
||||
var frame = SuiteLinkFrameReader.ParseFrame(SuiteLinkConnectCodec.Encode(options));
|
||||
var payload = frame.Payload.Span;
|
||||
var index = 0;
|
||||
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("App").Length;
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Topic").Length;
|
||||
index += 3;
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Client").Length;
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Node").Length;
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("User").Length;
|
||||
index += SuiteLinkEncoding.EncodeLengthPrefixedUtf16("Server").Length;
|
||||
index += 20;
|
||||
|
||||
var timezone1 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out var timezone1ConsumedBytes);
|
||||
index += timezone1ConsumedBytes + 38;
|
||||
var timezone2 = SuiteLinkEncoding.DecodeNullTerminatedUtf16(payload[index..], out _);
|
||||
|
||||
Assert.Equal("UTC", timezone1);
|
||||
Assert.Equal("UTC", timezone2);
|
||||
}
|
||||
}
|
||||
111
tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs
Normal file
111
tests/SuiteLink.Client.Tests/Protocol/SuiteLinkEncodingTests.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkEncodingTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncodeLengthPrefixedUtf16_WithAsciiText_WritesCharacterCountAndUtf16Bytes()
|
||||
{
|
||||
var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("AB");
|
||||
|
||||
Assert.Equal(new byte[] { 0x02, 0x41, 0x00, 0x42, 0x00 }, bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeNullTerminatedUtf16_ReadsStringAndConsumedBytes()
|
||||
{
|
||||
var buffer = new byte[] { 0x48, 0x00, 0x69, 0x00, 0x00, 0x00, 0x20, 0x00 };
|
||||
|
||||
var text = SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out var consumed);
|
||||
|
||||
Assert.Equal("Hi", text);
|
||||
Assert.Equal(6, consumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteAndReadUInt32LittleEndian_RoundTripsValue()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
|
||||
SuiteLinkEncoding.WriteUInt32LittleEndian(bytes, 0x11223344);
|
||||
var value = SuiteLinkEncoding.ReadUInt32LittleEndian(bytes);
|
||||
|
||||
Assert.Equal((uint)0x11223344, value);
|
||||
Assert.Equal(new byte[] { 0x44, 0x33, 0x22, 0x11 }, bytes.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileTimeToUtcDateTime_ConvertsKnownEpochValue()
|
||||
{
|
||||
const long unixEpochFileTime = 116444736000000000L;
|
||||
|
||||
var value = SuiteLinkEncoding.FileTimeToUtcDateTime(unixEpochFileTime);
|
||||
|
||||
Assert.Equal(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeNullTerminatedUtf16_WithOddLength_ThrowsFormatException()
|
||||
{
|
||||
var buffer = new byte[] { 0x41, 0x00, 0x00 };
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeNullTerminatedUtf16_WithoutNullTerminator_ThrowsFormatException()
|
||||
{
|
||||
var buffer = new byte[] { 0x41, 0x00, 0x42, 0x00 };
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkEncoding.DecodeNullTerminatedUtf16(buffer, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeLengthPrefixedUtf16_WhenInputIsTooLong_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
var value = new string('A', 256);
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => SuiteLinkEncoding.EncodeLengthPrefixedUtf16(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeLengthPrefixedUtf16_WithSurrogatePair_PrefixUsesUtf16CodeUnits()
|
||||
{
|
||||
var bytes = SuiteLinkEncoding.EncodeLengthPrefixedUtf16("A\U0001F600");
|
||||
|
||||
Assert.Equal(3, bytes[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadUInt32LittleEndian_WhenInputIsTooShort_ThrowsFormatException()
|
||||
{
|
||||
var buffer = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkEncoding.ReadUInt32LittleEndian(buffer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteAndReadSingleLittleEndian_RoundTripsNonTrivialValue()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
const float expected = 123.4567f;
|
||||
|
||||
SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected);
|
||||
var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes);
|
||||
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteAndReadSingleLittleEndian_RoundTripsNaN()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
var expected = float.NaN;
|
||||
|
||||
SuiteLinkEncoding.WriteSingleLittleEndian(bytes, expected);
|
||||
var value = SuiteLinkEncoding.ReadSingleLittleEndian(bytes);
|
||||
|
||||
Assert.True(float.IsNaN(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkFrameReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseFrame_WithValidFrame_ParsesMessageTypeAndPayload()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x05, 0x00,
|
||||
0x00, 0x09,
|
||||
0x01, 0x02,
|
||||
0xA5
|
||||
};
|
||||
|
||||
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
|
||||
|
||||
Assert.Equal((ushort)0x0900, frame.MessageType);
|
||||
Assert.Equal(new byte[] { 0x01, 0x02 }, frame.Payload.ToArray());
|
||||
Assert.Equal((ushort)5, frame.RemainingLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithInvalidMarker_ThrowsFormatException()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x03, 0x00,
|
||||
0x40, 0x24,
|
||||
0x00
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
|
||||
|
||||
Assert.Contains("marker", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithTooShortInput_ThrowsFormatException()
|
||||
{
|
||||
var bytes = new byte[] { 0x03, 0x00, 0x40, 0x24 };
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithRemainingLengthBelowMinimum_ThrowsFormatException()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x02, 0x00,
|
||||
0x40, 0x24
|
||||
};
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithTruncatedInput_ThrowsFormatException()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x05, 0x00,
|
||||
0x40, 0x24,
|
||||
0x01,
|
||||
0xA5
|
||||
};
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseFrame_WithExtraBytes_ReturnsFrameAndConsumedLength()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x03, 0x00,
|
||||
0x40, 0x24,
|
||||
0xA5,
|
||||
0xFF, 0xEE
|
||||
};
|
||||
|
||||
var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out var frame, out var consumed);
|
||||
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(5, consumed);
|
||||
Assert.Equal((ushort)0x2440, frame.MessageType);
|
||||
Assert.True(frame.Payload.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFrame_WithExtraBytes_ThrowsFormatException()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x03, 0x00,
|
||||
0x40, 0x24,
|
||||
0xA5,
|
||||
0xFF
|
||||
};
|
||||
|
||||
Assert.Throws<FormatException>(() => SuiteLinkFrameReader.ParseFrame(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseFrame_WithIncompleteBuffer_ReturnsFalse()
|
||||
{
|
||||
var bytes = new byte[]
|
||||
{
|
||||
0x05, 0x00,
|
||||
0x00, 0x09,
|
||||
0x01
|
||||
};
|
||||
|
||||
var parsed = SuiteLinkFrameReader.TryParseFrame(bytes, out _, out var consumed);
|
||||
|
||||
Assert.False(parsed);
|
||||
Assert.Equal(0, consumed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkFrameWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriteFrame_WithEmptyPayload_WritesHeaderAndMarker()
|
||||
{
|
||||
var bytes = SuiteLinkFrameWriter.WriteFrame(0x2440, []);
|
||||
|
||||
Assert.Equal(5, bytes.Length);
|
||||
Assert.Equal(0x03, bytes[0]);
|
||||
Assert.Equal(0x00, bytes[1]);
|
||||
Assert.Equal(0x40, bytes[2]);
|
||||
Assert.Equal(0x24, bytes[3]);
|
||||
Assert.Equal(0xA5, bytes[4]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Buffers.Binary;
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkHandshakeCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncodeNormalQueryHandshake_WritesLengthMagicsAndIdentityStrings()
|
||||
{
|
||||
var bytes = SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
||||
targetApplication: "Intouch",
|
||||
sourceNode: "NodeA",
|
||||
sourceUser: "UserA");
|
||||
|
||||
Assert.Equal(bytes.Length - 1, bytes[0]);
|
||||
Assert.Equal(SuiteLinkHandshakeCodec.QueryMagic.ToArray(), bytes.AsSpan(1, 16).ToArray());
|
||||
Assert.Equal(SuiteLinkHandshakeCodec.UnknownQueryMagic.ToArray(), bytes.AsSpan(17, 16).ToArray());
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(33, 4)));
|
||||
|
||||
var expectedApp = SuiteLinkEncoding.EncodeNullTerminatedUtf16("Intouch");
|
||||
var expectedNode = SuiteLinkEncoding.EncodeNullTerminatedUtf16("NodeA");
|
||||
var expectedUser = SuiteLinkEncoding.EncodeNullTerminatedUtf16("UserA");
|
||||
var payload = bytes.AsSpan(37);
|
||||
|
||||
Assert.True(payload.StartsWith(expectedApp));
|
||||
payload = payload[expectedApp.Length..];
|
||||
Assert.True(payload.StartsWith(expectedNode));
|
||||
payload = payload[expectedNode.Length..];
|
||||
Assert.True(payload.StartsWith(expectedUser));
|
||||
}
|
||||
|
||||
[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 ack = SuiteLinkHandshakeCodec.ParseNormalHandshakeAck(frame);
|
||||
|
||||
Assert.Equal(0x0001, ack.MessageType);
|
||||
Assert.Equal(new byte[] { 0xA1, 0xB2, 0xC3 }, ack.Data.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeNormalQueryHandshake_WhenPayloadExceedsOneByteLength_ThrowsWithPayloadContext()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
SuiteLinkHandshakeCodec.EncodeNormalQueryHandshake(
|
||||
targetApplication: "App",
|
||||
sourceNode: new string('N', 80),
|
||||
sourceUser: new string('U', 80)));
|
||||
|
||||
Assert.Equal("payloadLength", ex.ParamName);
|
||||
Assert.Contains("Total handshake payload", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
public sealed class SuiteLinkSubscriptionCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncodeAdvise_WritesExpectedGoldenVectorWithCallerTagId()
|
||||
{
|
||||
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];
|
||||
|
||||
Assert.Equal(expected, bytes);
|
||||
Assert.Equal(0x10, bytes[2]);
|
||||
Assert.Equal(0x80, bytes[3]);
|
||||
Assert.Equal(SuiteLinkSubscriptionCodec.AdviseMessageType, frame.MessageType);
|
||||
Assert.Equal(0x11223344u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeAdviseWithoutTagId_UsesExplicitDefaultTagIdOfZero()
|
||||
{
|
||||
var bytes = SuiteLinkSubscriptionCodec.EncodeAdvise("Pump001.Run");
|
||||
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
|
||||
Assert.Equal(0u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span[..4]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeUnadvise_WritesExpectedGoldenVector()
|
||||
{
|
||||
var bytes = SuiteLinkSubscriptionCodec.EncodeUnadvise(0x78563412);
|
||||
var frame = SuiteLinkFrameReader.ParseFrame(bytes);
|
||||
byte[] expected = [0x07, 0x00, 0x04, 0x80, 0x12, 0x34, 0x56, 0x78, 0xA5];
|
||||
|
||||
Assert.Equal(expected, bytes);
|
||||
Assert.Equal(0x04, bytes[2]);
|
||||
Assert.Equal(0x80, bytes[3]);
|
||||
Assert.Equal(SuiteLinkSubscriptionCodec.UnadviseMessageType, frame.MessageType);
|
||||
Assert.Equal(4, frame.Payload.Length);
|
||||
Assert.Equal(0x78563412u, SuiteLinkEncoding.ReadUInt32LittleEndian(frame.Payload.Span));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAdviseAck_ParsesTagIdFromFixedVector()
|
||||
{
|
||||
// remaining=0x0008, type=0x0003, payload={tag_id=0x78563412, unknown=0x00}, marker=0xA5
|
||||
byte[] frame = [0x08, 0x00, 0x03, 0x00, 0x12, 0x34, 0x56, 0x78, 0x00, 0xA5];
|
||||
|
||||
var ack = SuiteLinkSubscriptionCodec.DecodeAdviseAck(frame);
|
||||
|
||||
Assert.Equal(0x78563412u, ack.TagId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeAdviseAckMany_ParsesTwoAckItems()
|
||||
{
|
||||
// remaining=0x000D, type=0x0003, payload={item1,item2}, marker=0xA5
|
||||
byte[] frame =
|
||||
[
|
||||
0x0D, 0x00, 0x03, 0x00,
|
||||
0x12, 0x34, 0x56, 0x78, 0x00,
|
||||
0xAA, 0xBB, 0xCC, 0xDD, 0x01,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var acks = SuiteLinkSubscriptionCodec.DecodeAdviseAckMany(frame);
|
||||
|
||||
Assert.Equal(2, acks.Count);
|
||||
Assert.Equal(0x78563412u, acks[0].TagId);
|
||||
Assert.Equal(0xDDCCBBAAu, acks[1].TagId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Text;
|
||||
using SuiteLink.Client.Protocol;
|
||||
|
||||
namespace SuiteLink.Client.Tests.Protocol;
|
||||
|
||||
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
|
||||
];
|
||||
Assert.Equal(0x09, frame[2]);
|
||||
Assert.Equal(0x00, frame[3]);
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame);
|
||||
|
||||
Assert.Equal(0x1234u, update.TagId);
|
||||
Assert.Equal(0x00C0, update.Quality);
|
||||
Assert.Equal(10, update.ElapsedMilliseconds);
|
||||
Assert.True(update.Value.TryGetBoolean(out var value));
|
||||
Assert.True(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdate_DecodesIntegerValue()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x10, 0x00, 0x09, 0x00,
|
||||
0x78, 0x56, 0x34, 0x12,
|
||||
0x01, 0x00,
|
||||
0xC0, 0x00,
|
||||
0x02,
|
||||
0x2A, 0x00, 0x00, 0x00,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame);
|
||||
|
||||
Assert.Equal(0x12345678u, update.TagId);
|
||||
Assert.True(update.Value.TryGetInt32(out var value));
|
||||
Assert.Equal(42, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdate_DecodesRealValue()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x10, 0x00, 0x09, 0x00,
|
||||
0x34, 0x12, 0x00, 0x00,
|
||||
0x01, 0x00,
|
||||
0xC0, 0x00,
|
||||
0x03,
|
||||
0x00, 0x00, 0x48, 0x41,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame);
|
||||
|
||||
Assert.True(update.Value.TryGetFloat32(out var value));
|
||||
Assert.Equal(12.5f, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdate_DecodesMessageValue()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x10, 0x00, 0x09, 0x00,
|
||||
0x22, 0x22, 0x00, 0x00,
|
||||
0x02, 0x00,
|
||||
0xC0, 0x00,
|
||||
0x04,
|
||||
0x02, 0x00,
|
||||
0x4F, 0x4B,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame);
|
||||
|
||||
Assert.True(update.Value.TryGetString(out var value));
|
||||
Assert.Equal("OK", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdateMany_ParsesTwoItemsFromSingleFrame()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x1A, 0x00, 0x09, 0x00,
|
||||
0x11, 0x11, 0x00, 0x00, 0x01, 0x00, 0xC0, 0x00, 0x01, 0x01,
|
||||
0x22, 0x22, 0x00, 0x00, 0x02, 0x00, 0xC0, 0x00, 0x02, 0x2A, 0x00, 0x00, 0x00,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var updates = SuiteLinkUpdateCodec.DecodeMany(frame);
|
||||
|
||||
Assert.Equal(2, updates.Count);
|
||||
Assert.Equal(0x1111u, updates[0].TagId);
|
||||
Assert.True(updates[0].Value.TryGetBoolean(out var boolValue));
|
||||
Assert.True(boolValue);
|
||||
|
||||
Assert.Equal(0x2222u, updates[1].TagId);
|
||||
Assert.True(updates[1].Value.TryGetInt32(out var intValue));
|
||||
Assert.Equal(42, intValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdate_WithDefaultMessageEncoding_UsesLatin1LosslessMapping()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x0F, 0x00, 0x09, 0x00,
|
||||
0x33, 0x33, 0x00, 0x00,
|
||||
0x01, 0x00,
|
||||
0xC0, 0x00,
|
||||
0x04,
|
||||
0x01, 0x00,
|
||||
0xE9,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame);
|
||||
|
||||
Assert.True(update.Value.TryGetString(out var value));
|
||||
Assert.Equal("\u00E9", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeUpdate_WithExplicitUtf8MessageEncoding_UsesProvidedEncoding()
|
||||
{
|
||||
byte[] frame =
|
||||
[
|
||||
0x10, 0x00, 0x09, 0x00,
|
||||
0x44, 0x44, 0x00, 0x00,
|
||||
0x01, 0x00,
|
||||
0xC0, 0x00,
|
||||
0x04,
|
||||
0x02, 0x00,
|
||||
0xC3, 0xA9,
|
||||
0xA5
|
||||
];
|
||||
|
||||
var update = SuiteLinkUpdateCodec.Decode(frame, Encoding.UTF8);
|
||||
|
||||
Assert.True(update.Value.TryGetString(out var value));
|
||||
Assert.Equal("\u00E9", value);
|
||||
}
|
||||
}
|
||||
25
tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj
Normal file
25
tests/SuiteLink.Client.Tests/SuiteLink.Client.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<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>
|
||||
123
tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs
Normal file
123
tests/SuiteLink.Client.Tests/SuiteLinkConnectionOptionsTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using SuiteLink.Client;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkConnectionOptionsTests
|
||||
{
|
||||
public static TheoryData<string?> InvalidRequiredValues =>
|
||||
new()
|
||||
{
|
||||
null,
|
||||
"",
|
||||
" ",
|
||||
"\t"
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidHost_ThrowsArgumentException(string? host)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(host: host!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidApplication_ThrowsArgumentException(string? application)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(application: application!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidTopic_ThrowsArgumentException(string? topic)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(topic: topic!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidClientName_ThrowsArgumentException(string? clientName)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(clientName: clientName!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidClientNode_ThrowsArgumentException(string? clientNode)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(clientNode: clientNode!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidUserName_ThrowsArgumentException(string? userName)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(userName: userName!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequiredValues))]
|
||||
public void Constructor_InvalidServerNode_ThrowsArgumentException(string? serverNode)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Create(serverNode: serverNode!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(65536)]
|
||||
public void Constructor_InvalidPort_ThrowsArgumentOutOfRangeException(int port)
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Create(port: port));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NoTimezone_UsesUtcByDefault()
|
||||
{
|
||||
var options = Create(timezone: null);
|
||||
|
||||
Assert.Equal("UTC", options.Timezone);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t")]
|
||||
public void Constructor_WhitespaceTimezone_UsesUtcByDefault(string timezone)
|
||||
{
|
||||
var options = Create(timezone: timezone);
|
||||
|
||||
Assert.Equal("UTC", options.Timezone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ExplicitTimezone_PreservesProvidedValue()
|
||||
{
|
||||
var options = Create(timezone: "America/Indiana/Indianapolis");
|
||||
|
||||
Assert.Equal("America/Indiana/Indianapolis", options.Timezone);
|
||||
}
|
||||
|
||||
private static SuiteLinkConnectionOptions Create(
|
||||
string host = "127.0.0.1",
|
||||
string application = "TestApp",
|
||||
string topic = "TestTopic",
|
||||
string clientName = "Client",
|
||||
string clientNode = "Node",
|
||||
string userName = "User",
|
||||
string serverNode = "Server",
|
||||
string? timezone = null,
|
||||
int port = 5413)
|
||||
{
|
||||
return new SuiteLinkConnectionOptions(
|
||||
host,
|
||||
application,
|
||||
topic,
|
||||
clientName,
|
||||
clientNode,
|
||||
userName,
|
||||
serverNode,
|
||||
timezone,
|
||||
port);
|
||||
}
|
||||
}
|
||||
76
tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs
Normal file
76
tests/SuiteLink.Client.Tests/SuiteLinkValueTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using SuiteLink.Client;
|
||||
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public sealed class SuiteLinkValueTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_ValueIsNone_AndTryGetMethodsReturnFalse()
|
||||
{
|
||||
var value = default(SuiteLinkValue);
|
||||
|
||||
Assert.Equal(SuiteLinkValueKind.None, value.Kind);
|
||||
Assert.False(value.TryGetBoolean(out _));
|
||||
Assert.False(value.TryGetInt32(out _));
|
||||
Assert.False(value.TryGetFloat32(out _));
|
||||
Assert.False(value.TryGetString(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromBoolean_CreatesBooleanValue_AndTryGetBooleanSucceeds()
|
||||
{
|
||||
var value = SuiteLinkValue.FromBoolean(true);
|
||||
|
||||
Assert.Equal(SuiteLinkValueKind.Boolean, value.Kind);
|
||||
Assert.True(value.TryGetBoolean(out var boolValue));
|
||||
Assert.True(boolValue);
|
||||
Assert.False(value.TryGetInt32(out _));
|
||||
Assert.False(value.TryGetFloat32(out _));
|
||||
Assert.False(value.TryGetString(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromInt32_CreatesInt32Value_AndTryGetInt32Succeeds()
|
||||
{
|
||||
var value = SuiteLinkValue.FromInt32(42);
|
||||
|
||||
Assert.Equal(SuiteLinkValueKind.Int32, value.Kind);
|
||||
Assert.True(value.TryGetInt32(out var intValue));
|
||||
Assert.Equal(42, intValue);
|
||||
Assert.False(value.TryGetBoolean(out _));
|
||||
Assert.False(value.TryGetFloat32(out _));
|
||||
Assert.False(value.TryGetString(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFloat32_CreatesFloat32Value_AndTryGetFloat32Succeeds()
|
||||
{
|
||||
var value = SuiteLinkValue.FromFloat32(12.5f);
|
||||
|
||||
Assert.Equal(SuiteLinkValueKind.Float32, value.Kind);
|
||||
Assert.True(value.TryGetFloat32(out var floatValue));
|
||||
Assert.Equal(12.5f, floatValue);
|
||||
Assert.False(value.TryGetBoolean(out _));
|
||||
Assert.False(value.TryGetInt32(out _));
|
||||
Assert.False(value.TryGetString(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_CreatesStringValue_AndTryGetStringSucceeds()
|
||||
{
|
||||
var value = SuiteLinkValue.FromString("tag-value");
|
||||
|
||||
Assert.Equal(SuiteLinkValueKind.String, value.Kind);
|
||||
Assert.True(value.TryGetString(out var stringValue));
|
||||
Assert.Equal("tag-value", stringValue);
|
||||
Assert.False(value.TryGetBoolean(out _));
|
||||
Assert.False(value.TryGetInt32(out _));
|
||||
Assert.False(value.TryGetFloat32(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_NullValue_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => SuiteLinkValue.FromString(null!));
|
||||
}
|
||||
}
|
||||
10
tests/SuiteLink.Client.Tests/UnitTest1.cs
Normal file
10
tests/SuiteLink.Client.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SuiteLink.Client.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user