using Google.Protobuf.WellKnownTypes; using ScadaLink.Communication.Grpc; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.Communication.Tests.Grpc; public class SiteStreamGrpcClientTests { [Fact] public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly() { var ts = DateTimeOffset.UtcNow; var evt = new SiteStreamEvent { CorrelationId = "corr-1", AttributeChanged = new AttributeValueUpdate { InstanceUniqueName = "Site1.Pump01", AttributePath = "Modules.IO", AttributeName = "Temperature", Value = "42.5", Quality = Quality.Good, Timestamp = Timestamp.FromDateTimeOffset(ts) } }; var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt); var attr = Assert.IsType(result); Assert.Equal("Site1.Pump01", attr.InstanceUniqueName); Assert.Equal("Modules.IO", attr.AttributePath); Assert.Equal("Temperature", attr.AttributeName); Assert.Equal("42.5", attr.Value); Assert.Equal("Good", attr.Quality); Assert.Equal(ts, attr.Timestamp); } [Fact] public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly() { var ts = DateTimeOffset.UtcNow; var evt = new SiteStreamEvent { CorrelationId = "corr-2", AlarmChanged = new AlarmStateUpdate { InstanceUniqueName = "Site1.Motor01", AlarmName = "OverTemp", State = AlarmStateEnum.AlarmStateActive, Priority = 3, Timestamp = Timestamp.FromDateTimeOffset(ts) } }; var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt); var alarm = Assert.IsType(result); Assert.Equal("Site1.Motor01", alarm.InstanceUniqueName); Assert.Equal("OverTemp", alarm.AlarmName); Assert.Equal(AlarmState.Active, alarm.State); Assert.Equal(3, alarm.Priority); Assert.Equal(ts, alarm.Timestamp); } [Fact] public void ConvertToDomainEvent_UnknownEvent_ReturnsNull() { var evt = new SiteStreamEvent { CorrelationId = "corr-3" // No oneof case set }; var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt); Assert.Null(result); } [Theory] [InlineData(Quality.Good, "Good")] [InlineData(Quality.Uncertain, "Uncertain")] [InlineData(Quality.Bad, "Bad")] [InlineData(Quality.Unspecified, "Unknown")] public void MapQuality_AllValues(Quality input, string expected) { var result = SiteStreamGrpcClient.MapQuality(input); Assert.Equal(expected, result); } [Theory] [InlineData(AlarmStateEnum.AlarmStateNormal, AlarmState.Normal)] [InlineData(AlarmStateEnum.AlarmStateActive, AlarmState.Active)] [InlineData(AlarmStateEnum.AlarmStateUnspecified, AlarmState.Normal)] public void MapAlarmState_AllValues(AlarmStateEnum input, AlarmState expected) { var result = SiteStreamGrpcClient.MapAlarmState(input); Assert.Equal(expected, result); } [Theory] [InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)] [InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)] [InlineData(AlarmLevelEnum.AlarmLevelLowLow, AlarmLevel.LowLow)] [InlineData(AlarmLevelEnum.AlarmLevelHigh, AlarmLevel.High)] [InlineData(AlarmLevelEnum.AlarmLevelHighHigh, AlarmLevel.HighHigh)] public void MapAlarmLevel_AllValues(AlarmLevelEnum input, AlarmLevel expected) { var result = SiteStreamGrpcClient.MapAlarmLevel(input); Assert.Equal(expected, result); } [Fact] public void ConvertToDomainEvent_AlarmChanged_PreservesLevel() { // Round-trip: a HiLo alarm emitted at HighHigh must come through with Level intact. var evt = new SiteStreamEvent { CorrelationId = "test", AlarmChanged = new AlarmStateUpdate { InstanceUniqueName = "Pump1", AlarmName = "TempAlarm", State = AlarmStateEnum.AlarmStateActive, Priority = 900, Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), Level = AlarmLevelEnum.AlarmLevelHighHigh } }; var domain = SiteStreamGrpcClient.ConvertToDomainEvent(evt) as AlarmStateChanged; Assert.NotNull(domain); Assert.Equal(AlarmState.Active, domain.State); Assert.Equal(AlarmLevel.HighHigh, domain.Level); Assert.Equal(900, domain.Priority); } [Fact] public void Unsubscribe_CancelsSubscription() { // We can't easily test the full Subscribe flow without a real gRPC server, // but we can test the Unsubscribe path by registering a CTS directly. // Use the internal AddSubscription helper for testability. var client = SiteStreamGrpcClient.CreateForTesting(); var cts = new CancellationTokenSource(); client.AddSubscriptionForTesting("corr-test", cts); Assert.False(cts.IsCancellationRequested); client.Unsubscribe("corr-test"); Assert.True(cts.IsCancellationRequested); } [Fact] public void Unsubscribe_NonExistent_DoesNotThrow() { var client = SiteStreamGrpcClient.CreateForTesting(); client.Unsubscribe("does-not-exist"); // Should not throw } [Fact] public async Task DisposeAsync_CancelsAllSubscriptions() { var client = SiteStreamGrpcClient.CreateForTesting(); var cts1 = new CancellationTokenSource(); var cts2 = new CancellationTokenSource(); client.AddSubscriptionForTesting("corr-1", cts1); client.AddSubscriptionForTesting("corr-2", cts2); await client.DisposeAsync(); Assert.True(cts1.IsCancellationRequested); Assert.True(cts2.IsCancellationRequested); } // --- Communication-003 regression tests --- [Fact] public void RegisterSubscription_ReusedCorrelationId_CancelsAndDisposesPriorCts() { // Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race). // Inserting the second must cancel + dispose the first so it does not leak. var client = SiteStreamGrpcClient.CreateForTesting(); var first = new CancellationTokenSource(); var second = new CancellationTokenSource(); client.RegisterSubscription("corr-shared", first); client.RegisterSubscription("corr-shared", second); Assert.True(first.IsCancellationRequested); // Disposed CTS throws ObjectDisposedException when its token is touched. Assert.Throws(() => _ = first.Token); // The second (live) CTS must remain intact. Assert.False(second.IsCancellationRequested); } [Fact] public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement() { // First call's finally must NOT remove the second call's live entry. var client = SiteStreamGrpcClient.CreateForTesting(); var first = new CancellationTokenSource(); var second = new CancellationTokenSource(); client.RegisterSubscription("corr-shared", first); // A racing second SubscribeAsync replaces the entry. client.RegisterSubscription("corr-shared", second); // The first call's finally runs and tries to remove its (already-replaced) entry. client.RemoveSubscription("corr-shared", first); // The live (second) subscription must still be cancellable via Unsubscribe. Assert.False(second.IsCancellationRequested); client.Unsubscribe("corr-shared"); Assert.True(second.IsCancellationRequested); } }