feat: bootstrap suitelink tag client codecs

This commit is contained in:
Joseph Doherty
2026-03-16 14:43:31 -04:00
commit 731bfe2237
30 changed files with 3429 additions and 0 deletions

View File

@@ -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);
}
}

View 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));
}
}

View File

@@ -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);
}
}

View File

@@ -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]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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>

View 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);
}
}

View 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!));
}
}

View File

@@ -0,0 +1,10 @@
namespace SuiteLink.Client.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
Assert.True(true);
}
}