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(() => 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); } }