using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using Akka.TestKit; using ScadaLink.Commons.Messages.DataConnection; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Regression tests for SiteRuntime-001: Instance.SetAttribute must route writes /// to the Data Connection Layer for data-sourced attributes instead of persisting /// a local static override. /// public class InstanceActorSetAttributeTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorSetAttributeTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"instance-setattr-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions(); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config, IActorRef? dclManager) { return ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance, dclManager))); } /// /// Drains the startup the Instance Actor emits /// to the DCL in PreStart, then returns the next . /// private static WriteTagRequest ExpectWriteTag(TestProbe dclProbe) => dclProbe.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); private static FlattenedConfiguration DataSourcedConfig(string instanceName) => new() { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Setpoint", Value = "10", DataType = "Double", DataSourceReference = "/Motor/Setpoint", BoundDataConnectionName = "OpcServer1" } ] }; [Fact] public async Task SetAttribute_DataSourcedAttribute_IssuesDclWriteAndDoesNotPersistOverride() { var config = DataSourcedConfig("PumpDcl1"); var dclProbe = CreateTestProbe(); var actor = CreateInstanceActor("PumpDcl1", config, dclProbe.Ref); actor.Tell(new SetStaticAttributeCommand( "corr-dcl", "PumpDcl1", "Setpoint", "55", DateTimeOffset.UtcNow)); // The Instance Actor must forward a WriteTagRequest to the DCL manager. var write = ExpectWriteTag(dclProbe); Assert.Equal("OpcServer1", write.ConnectionName); Assert.Equal("/Motor/Setpoint", write.TagPath); Assert.Equal("55", write.Value); // DCL confirms the write. dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); // No static override should be persisted for a data-sourced attribute. await Task.Delay(300); var overrides = await _storage.GetStaticOverridesAsync("PumpDcl1"); Assert.Empty(overrides); } [Fact] public void SetAttribute_DataSourcedAttribute_DoesNotOptimisticallyUpdateMemory() { var config = DataSourcedConfig("PumpDcl2"); var dclProbe = CreateTestProbe(); var actor = CreateInstanceActor("PumpDcl2", config, dclProbe.Ref); actor.Tell(new SetStaticAttributeCommand( "corr-dcl2", "PumpDcl2", "Setpoint", "999", DateTimeOffset.UtcNow)); var write = ExpectWriteTag(dclProbe); dclProbe.Reply(new WriteTagResponse(write.CorrelationId, true, null, DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(5)); // In-memory value must still be the original config value — it is only // updated when the subscription delivers the confirmed device value. actor.Tell(new GetAttributeRequest("corr-get", "PumpDcl2", "Setpoint", DateTimeOffset.UtcNow)); var get = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("10", get.Value?.ToString()); } [Fact] public void SetAttribute_DataSourcedAttribute_DclWriteFailure_ReturnedToCaller() { var config = DataSourcedConfig("PumpDcl3"); var dclProbe = CreateTestProbe(); var actor = CreateInstanceActor("PumpDcl3", config, dclProbe.Ref); actor.Tell(new SetStaticAttributeCommand( "corr-dcl3", "PumpDcl3", "Setpoint", "42", DateTimeOffset.UtcNow)); var write = ExpectWriteTag(dclProbe); dclProbe.Reply(new WriteTagResponse(write.CorrelationId, false, "device rejected write", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.False(response.Success); Assert.Contains("device rejected write", response.ErrorMessage); } [Fact] public async Task SetAttribute_StaticAttribute_StillPersistsOverrideAndDoesNotCallDcl() { var config = new FlattenedConfiguration { InstanceUniqueName = "PumpStatic1", Attributes = [ new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" } ] }; var dclProbe = CreateTestProbe(); var actor = CreateInstanceActor("PumpStatic1", config, dclProbe.Ref); actor.Tell(new SetStaticAttributeCommand( "corr-static", "PumpStatic1", "Label", "Backup", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); // DCL must NOT receive a write for a static attribute. dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); await Task.Delay(300); var overrides = await _storage.GetStaticOverridesAsync("PumpStatic1"); Assert.Single(overrides); Assert.Equal("Backup", overrides["Label"]); } }