using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Lifecycle; 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; /// /// Tests for InstanceActor: attribute loading, static overrides, and persistence. /// public class InstanceActorTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"instance-actor-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(); } private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) { return ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, // no stream manager in tests _options, NullLogger.Instance))); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } [Fact] public void InstanceActor_LoadsAttributesFromConfig() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }, new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" } ] }; var actor = CreateInstanceActor("Pump1", config); // Query for an attribute that exists actor.Tell(new GetAttributeRequest( "corr-1", "Pump1", "Temperature", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.True(response.Found); Assert.Equal("98.6", response.Value?.ToString()); Assert.Equal("corr-1", response.CorrelationId); } [Fact] public void InstanceActor_GetAttribute_NotFound_ReturnsFalse() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new GetAttributeRequest( "corr-2", "Pump1", "NonExistent", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.False(response.Found); Assert.Null(response.Value); } [Fact] public void InstanceActor_SetStaticAttribute_UpdatesInMemory() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" } ] }; var actor = CreateInstanceActor("Pump1", config); // A static attribute write replies with SetStaticAttributeResponse. actor.Tell(new SetStaticAttributeCommand( "corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow)); var setResponse = ExpectMsg(); Assert.True(setResponse.Success); // Verify the value changed in memory actor.Tell(new GetAttributeRequest( "corr-4", "Pump1", "Temperature", DateTimeOffset.UtcNow)); var getResponse = ExpectMsg(); Assert.True(getResponse.Found); Assert.Equal("100.0", getResponse.Value?.ToString()); } [Fact] public async Task InstanceActor_SetStaticAttribute_PersistsToSQLite() { var config = new FlattenedConfiguration { InstanceUniqueName = "PumpPersist1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" } ] }; var actor = CreateInstanceActor("PumpPersist1", config); actor.Tell(new SetStaticAttributeCommand( "corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow)); // A static attribute write replies with SetStaticAttributeResponse once the // in-memory state is updated; then wait for the async SQLite persist. ExpectMsg(TimeSpan.FromSeconds(5)); await Task.Delay(500); // Verify it persisted to SQLite var overrides = await _storage.GetStaticOverridesAsync("PumpPersist1"); Assert.Single(overrides); Assert.Equal("100.0", overrides["Temperature"]); } [Fact] public async Task InstanceActor_LoadsStaticOverridesFromSQLite() { // Pre-populate overrides in SQLite await _storage.SetStaticOverrideAsync("PumpOverride1", "Temperature", "200.0"); var config = new FlattenedConfiguration { InstanceUniqueName = "PumpOverride1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" } ] }; var actor = CreateInstanceActor("PumpOverride1", config); // Wait for the async override loading to complete (PipeTo) await Task.Delay(1000); actor.Tell(new GetAttributeRequest( "corr-5", "PumpOverride1", "Temperature", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.True(response.Found); // The override value should take precedence over the config default Assert.Equal("200.0", response.Value?.ToString()); } [Fact] public async Task StaticOverride_ResetOnRedeployment() { // Set up an override await _storage.SetStaticOverrideAsync("PumpRedeploy", "Temperature", "200.0"); // Verify override exists var overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy"); Assert.Single(overrides); // Clear overrides (simulates what DeploymentManager does on redeployment) await _storage.ClearStaticOverridesAsync("PumpRedeploy"); overrides = await _storage.GetStaticOverridesAsync("PumpRedeploy"); Assert.Empty(overrides); // Create actor with fresh config -- should NOT have the override var config = new FlattenedConfiguration { InstanceUniqueName = "PumpRedeploy", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" } ] }; var actor = CreateInstanceActor("PumpRedeploy", config); await Task.Delay(1000); actor.Tell(new GetAttributeRequest( "corr-6", "PumpRedeploy", "Temperature", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.Equal("98.6", response.Value?.ToString()); } [Fact] public void InstanceActor_DataSourcedAttribute_StartsWithUncertainQuality() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "0", DataType = "Double", DataSourceReference = "/Motor/Temperature", BoundDataConnectionName = "OpcServer1" } ] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new GetAttributeRequest( "corr-quality-1", "Pump1", "Temperature", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.True(response.Found); Assert.Equal("Uncertain", response.Quality); } /// /// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the /// Deployment Manager (it stops / re-creates the Instance Actor itself and /// replies to the caller). The Instance Actor must NOT handle /// / /// — the dead handlers that replied with a misleading "success" /// acknowledgement were removed. Sending one to the Instance Actor now goes /// unhandled and produces no . /// [Fact] public void InstanceActor_DoesNotHandleDisableOrEnableCommands() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new DisableInstanceCommand("cmd-disable", "Pump1", DateTimeOffset.UtcNow)); ExpectNoMsg(TimeSpan.FromMilliseconds(500)); actor.Tell(new EnableInstanceCommand("cmd-enable", "Pump1", DateTimeOffset.UtcNow)); ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void InstanceActor_StaticAttribute_StartsWithGoodQuality() { var config = new FlattenedConfiguration { InstanceUniqueName = "Pump1", Attributes = [ new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" // No DataSourceReference — static attribute } ] }; var actor = CreateInstanceActor("Pump1", config); actor.Tell(new GetAttributeRequest( "corr-quality-2", "Pump1", "Label", DateTimeOffset.UtcNow)); var response = ExpectMsg(); Assert.True(response.Found); Assert.Equal("Good", response.Quality); } }