225 lines
7.8 KiB
C#
225 lines
7.8 KiB
C#
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<AttributeValueChanged>(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<AlarmStateChanged>(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<ObjectDisposedException>(() => _ = 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);
|
|
}
|
|
}
|