using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; 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; /// /// 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); // Set a static attribute -- response comes async via PipeTo actor.Tell(new SetStaticAttributeCommand( "corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow)); var setResponse = ExpectMsg(TimeSpan.FromSeconds(5)); 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)); ExpectMsg(TimeSpan.FromSeconds(5)); // Give async persistence time to complete 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); } [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); } }