Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
T

670 lines
25 KiB
C#

using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Tests for InstanceActor: attribute loading, static overrides, and persistence.
/// </summary>
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<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.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<InstanceActor>.Instance)));
}
void IDisposable.Dispose()
{
Shutdown();
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
// ── M1.6: site event log `instance_lifecycle` category ──────────────────
[Fact]
public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent()
{
var siteLog = new FakeSiteEventLogger();
var config = new FlattenedConfiguration
{
InstanceUniqueName = "LifecyclePump",
Attributes = [new ResolvedAttribute { CanonicalName = "T", Value = "1", DataType = "Int32" }]
};
ActorOf(Props.Create(() => new InstanceActor(
"LifecyclePump",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
null,
null,
new SingleServiceProvider(siteLog))));
AwaitAssert(() =>
{
var rows = siteLog.OfType("instance_lifecycle");
Assert.Contains(rows, r =>
r.Severity == "Info" &&
r.InstanceId == "LifecyclePump" &&
r.Source == "InstanceActor:LifecyclePump" &&
r.Message.Contains("started", StringComparison.OrdinalIgnoreCase));
}, TimeSpan.FromSeconds(2));
}
[Fact]
public void InstanceActor_Stop_EmitsInstanceLifecycleSiteEvent()
{
var siteLog = new FakeSiteEventLogger();
var config = new FlattenedConfiguration
{
InstanceUniqueName = "StoppedPump",
Attributes = [new ResolvedAttribute { CanonicalName = "T", Value = "1", DataType = "Int32" }]
};
var actor = ActorOf(Props.Create(() => new InstanceActor(
"StoppedPump",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
null,
null,
new SingleServiceProvider(siteLog))));
// Let PreStart land its started event, then stop the actor.
AwaitAssert(() => Assert.NotEmpty(siteLog.OfType("instance_lifecycle")),
TimeSpan.FromSeconds(2));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor, TimeSpan.FromSeconds(5));
AwaitAssert(() =>
{
var rows = siteLog.OfType("instance_lifecycle");
Assert.Contains(rows, r =>
r.Severity == "Info" &&
r.InstanceId == "StoppedPump" &&
r.Message.Contains("stopped", StringComparison.OrdinalIgnoreCase));
}, TimeSpan.FromSeconds(2));
}
[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<GetAttributeResponse>();
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<GetAttributeResponse>();
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<SetStaticAttributeResponse>();
Assert.True(setResponse.Success);
// Verify the value changed in memory
actor.Tell(new GetAttributeRequest(
"corr-4", "Pump1", "Temperature", DateTimeOffset.UtcNow));
var getResponse = ExpectMsg<GetAttributeResponse>();
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<SetStaticAttributeResponse>(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<GetAttributeResponse>();
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<GetAttributeResponse>();
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<GetAttributeResponse>();
Assert.True(response.Found);
Assert.Equal("Uncertain", response.Quality);
}
/// <summary>
/// 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
/// <see cref="DisableInstanceCommand"/> / <see cref="EnableInstanceCommand"/>
/// — the dead handlers that replied with a misleading "success"
/// acknowledgement were removed. Sending one to the Instance Actor now goes
/// unhandled and produces no <see cref="InstanceLifecycleResponse"/>.
/// </summary>
[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<GetAttributeResponse>();
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
}
/// <summary>
/// A single PLC tag can back more than one attribute — e.g. two composed
/// cooling-tank modules whose members both reference the one simulated
/// <c>ns=3;s=Tank.Level</c> node. A <see cref="TagValueUpdate"/> must fan out
/// to every attribute that references that tag path, not just the last one
/// registered: the tag-path → attribute map previously overwrote on a shared
/// tag, leaving all but one attribute permanently Uncertain.
/// </summary>
[Fact]
public void InstanceActor_TagUpdate_FansOutToEveryAttributeSharingTheTagPath()
{
const string sharedTag = "ns=3;s=Tank.Level";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Motor-1",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "CoolingTank.Level", Value = "0", DataType = "Int",
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
},
new ResolvedAttribute
{
CanonicalName = "CoolingTank2.Level", Value = "0", DataType = "Int",
DataSourceReference = sharedTag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Motor-1",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
dcl.Ref)));
// On startup the actor subscribes its data-sourced tags through the DCL.
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
// One value arrives for the tag that both attributes reference.
actor.Tell(new TagValueUpdate("PLC", sharedTag, 47, QualityCode.Good, DateTimeOffset.UtcNow));
// BOTH attributes must reflect it — not just the last-registered one.
foreach (var attrName in new[] { "CoolingTank.Level", "CoolingTank2.Level" })
{
actor.Tell(new GetAttributeRequest("corr-fanout", "Motor-1", attrName, DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("47", response.Value?.ToString());
Assert.Equal("Good", response.Quality);
}
}
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
{
var actor = ActorOf(Props.Create(() => new InstanceActor(
instanceName,
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
dcl.Ref)));
// On startup the actor subscribes its data-sourced tags through the DCL.
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
return actor;
}
/// <summary>
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
/// coerced into a typed <c>List&lt;int&gt;</c> whose elements match the
/// attribute's ElementDataType. The stored value must be a real list — not a
/// JSON string — so scripts read a typed collection.
/// </summary>
[Fact]
public void InstanceActor_DataSourcedListAttribute_CoercesArrayToTypedList()
{
const string tag = "ns=3;s=Pump.Setpoints";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-List",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoints", Value = null,
DataType = "List", ElementDataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-List", config, dcl);
// OPC UA delivers an array value (CLR array) for the List-typed tag.
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 10, 20, 30 }, QualityCode.Good, DateTimeOffset.UtcNow));
actor.Tell(new GetAttributeRequest("corr-list", "Pump-List", "Setpoints", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType<List<int>>(response.Value);
Assert.Equal(new[] { 10, 20, 30 }, list);
}
/// <summary>
/// MV-8: array elements coming in as a different CLR type (here, strings that
/// are valid integers) must still coerce to the declared element type.
/// </summary>
[Fact]
public void InstanceActor_DataSourcedListAttribute_CoercesElementTypes()
{
const string tag = "ns=3;s=Pump.Levels";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ListCoerce",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Levels", Value = null,
DataType = "List", ElementDataType = "Double",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-ListCoerce", config, dcl);
// Elements arrive as ints/strings but the attribute is List<double>.
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "2.5", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
actor.Tell(new GetAttributeRequest("corr-coerce", "Pump-ListCoerce", "Levels", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType<List<double>>(response.Value);
Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list);
}
/// <summary>
/// MV-8: an element that cannot be coerced to the declared element type must
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
/// stays alive and continues to answer queries).
/// </summary>
[Fact]
public void InstanceActor_DataSourcedListAttribute_ElementMismatch_SetsBadQuality_ActorAlive()
{
const string tag = "ns=3;s=Pump.Bad";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ListBad",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = null,
DataType = "List", ElementDataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-ListBad", config, dcl);
Watch(actor);
// "not-a-number" cannot be coerced to int → Bad quality, no crash.
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "not-a-number", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
actor.Tell(new GetAttributeRequest("corr-bad", "Pump-ListBad", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
// The actor must still be alive (no crash / restart) and serving.
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// MV-8 design contract: a failed coercion keeps the PRIOR value. A good
/// array is delivered first and stored as a typed list; a subsequent array
/// with a non-coercible element must NOT overwrite that value — the stored
/// value stays the prior list while the quality flips to <c>Bad</c>.
/// </summary>
[Fact]
public void InstanceActor_DataSourcedListAttribute_BadCoercion_PreservesPriorValue()
{
const string tag = "ns=3;s=Pump.Keep";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ListKeep",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = null,
DataType = "List", ElementDataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-ListKeep", config, dcl);
// (1) A good array establishes the prior value.
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 1, 2, 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
// (2) A second array with a non-coercible element must not overwrite it.
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 4, "not-a-number", 6 }, QualityCode.Good, DateTimeOffset.UtcNow));
// (3) The stored value is still the prior list; quality is Bad.
actor.Tell(new GetAttributeRequest("corr-keep", "Pump-ListKeep", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
var list = Assert.IsType<List<int>>(response.Value);
Assert.Equal(new[] { 1, 2, 3 }, list);
}
/// <summary>
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
/// pass-through behaviour — a scalar value is stored unchanged.
/// </summary>
[Fact]
public void InstanceActor_DataSourcedScalarAttribute_UnchangedByListPath()
{
const string tag = "ns=3;s=Pump.Speed";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-Scalar",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Speed", Value = "0", DataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-Scalar", config, dcl);
actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
actor.Tell(new GetAttributeRequest("corr-scalar", "Pump-Scalar", "Speed", DateTimeOffset.UtcNow));
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
Assert.Equal(1450, response.Value);
}
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
{
// The actor is Watch()ed; assert no Terminated arrives in the window.
// (Liveness is also proven by the preceding successful GetAttributeResponse.)
ExpectNoMsg(within);
}
}