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;
///
/// 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 */ }
}
// ── 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.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.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();
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);
}
///
/// A single PLC tag can back more than one attribute — e.g. two composed
/// cooling-tank modules whose members both reference the one simulated
/// ns=3;s=Tank.Level node. A 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.
///
[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.Instance,
dcl.Ref)));
// On startup the actor subscribes its data-sourced tags through the DCL.
dcl.ExpectMsg(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(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.Instance,
dcl.Ref)));
// On startup the actor subscribes its data-sourced tags through the DCL.
dcl.ExpectMsg(TimeSpan.FromSeconds(5));
return actor;
}
///
/// MV-8: when a data-sourced attribute is declared DataType.List, an
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
/// coerced into a typed List<int> 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.
///
[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(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { 10, 20, 30 }, list);
}
///
/// 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.
///
[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.
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(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list);
}
///
/// MV-8: an element that cannot be coerced to the declared element type must
/// set the attribute quality to Bad and must NOT crash the actor (it
/// stays alive and continues to answer queries).
///
[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(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));
}
///
/// 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 Bad.
///
[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(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { 1, 2, 3 }, list);
}
///
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
/// pass-through behaviour — a scalar value is stored unchanged.
///
[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(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
Assert.Equal(1450, response.Value);
}
///
/// MV (C1 fix): a WRITE to a data-sourced DataType.List attribute must
/// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the
/// canonical JSON string the script layer produced. The script path encodes
/// List<int> to "[10,20,30]"; HandleSetDataAttribute must
/// decode that back to a typed List<int> before building the
/// WriteTagRequest. We assert the captured WriteTagRequest.Value is the typed
/// list {10,20,30} — never the string "[10,20,30]".
///
[Fact]
public void InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString()
{
const string tag = "ns=3;s=Pump.Setpoints";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ListWrite",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoints", Value = null,
DataType = "List", ElementDataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-ListWrite", config, dcl);
// Script-style write: ScopeAccessors (AttributeValueCodec.Encode) has
// already encoded the script's List to the canonical JSON array string,
// which is an array of element STRINGS (not raw JSON numbers).
actor.Tell(new SetStaticAttributeCommand(
"corr-write", "Pump-ListWrite", "Setpoints", "[\"10\",\"20\",\"30\"]", DateTimeOffset.UtcNow));
// The DCL must receive a WriteTagRequest carrying a TYPED collection.
var write = dcl.ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("PLC", write.ConnectionName);
Assert.Equal(tag, write.TagPath);
Assert.IsNotType(write.Value);
var list = Assert.IsType>(write.Value);
Assert.Equal(new[] { 10, 20, 30 }, list);
// Complete the Ask so the actor replies success to the caller.
dcl.Reply(new WriteTagResponse("corr-write", true, null, DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
}
///
/// MV (C1 fix): a malformed value written to a data-sourced List attribute
/// must be REJECTED before reaching the DCL — Success=false and NO
/// WriteTagRequest is forwarded (mirrors the static-path malformed rejection).
///
[Fact]
public void InstanceActor_DataSourcedListWrite_Malformed_Rejected_NoDclWrite()
{
const string tag = "ns=3;s=Pump.Bad";
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ListWriteBad",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Setpoints", Value = null,
DataType = "List", ElementDataType = "Int32",
DataSourceReference = tag, BoundDataConnectionName = "PLC"
}
]
};
var dcl = CreateTestProbe();
var actor = CreateInstanceActorWithDcl("Pump-ListWriteBad", config, dcl);
// Malformed JSON (unterminated array, non-int element) → reject the write.
actor.Tell(new SetStaticAttributeCommand(
"corr-bad-write", "Pump-ListWriteBad", "Setpoints", "[\"a\"", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.False(response.Success);
Assert.NotNull(response.ErrorMessage);
// No write must reach the DCL.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
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);
}
// ── MV-7: static (authored) List attribute decode ──────────────────────
///
/// MV-7: a STATIC List attribute carries its default as the canonical JSON
/// array string. On load the actor must decode it to a typed list so a
/// script reading the attribute receives a real collection, not the raw
/// JSON string.
///
[Fact]
public void InstanceActor_StaticListAttribute_LoadsAsTypedList()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-StaticList",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-StaticList", config);
actor.Tell(new GetAttributeRequest("corr-sl", "Pump-StaticList", "Labels", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { "a", "b" }, list);
}
///
/// MV-7: a SetStaticAttribute write on a List attribute decodes the canonical
/// JSON value into a typed list for in-memory reads, but the PERSISTED form
/// (SQLite static override) must remain the canonical JSON string — never a
/// CLR-list .ToString().
///
[Fact]
public async Task InstanceActor_SetStaticListAttribute_ReadsTypedList_PersistsJsonString()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-SetList",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-SetList", config);
actor.Tell(new SetStaticAttributeCommand(
"corr-set-list", "Pump-SetList", "Labels", "[\"x\",\"y\"]", DateTimeOffset.UtcNow));
var setResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(setResponse.Success);
// In-memory read returns a typed list.
actor.Tell(new GetAttributeRequest("corr-get-list", "Pump-SetList", "Labels", DateTimeOffset.UtcNow));
var getResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(getResponse.Found);
var list = Assert.IsType>(getResponse.Value);
Assert.Equal(new[] { "x", "y" }, list);
// The persisted form is the canonical JSON string, NOT a CLR-list .ToString().
await Task.Delay(500);
var overrides = await _storage.GetStaticOverridesAsync("Pump-SetList");
Assert.Single(overrides);
Assert.Equal("[\"x\",\"y\"]", overrides["Labels"]);
}
///
/// MV-7: a persisted static override for a List attribute is a canonical JSON
/// string in SQLite; on load it must be decoded to a typed list, the same as
/// the config default.
///
[Fact]
public async Task InstanceActor_StaticListOverride_LoadsAsTypedList()
{
await _storage.SetStaticOverrideAsync("Pump-OverrideList", "Labels", "[\"p\",\"q\"]");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-OverrideList",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-OverrideList", config);
// Wait for the async override load (PipeTo) to apply.
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-ol", "Pump-OverrideList", "Labels", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { "p", "q" }, list);
}
///
/// MV-7: a malformed stored List value must NOT crash the actor — it loads
/// with quality Bad and the actor stays alive and answering.
///
[Fact]
public void InstanceActor_StaticListAttribute_Malformed_LoadsBadQuality_ActorAlive()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-BadList",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\"", // truncated JSON
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-BadList", config);
Watch(actor);
actor.Tell(new GetAttributeRequest("corr-bl", "Pump-BadList", "Labels", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
Assert.Null(response.Value);
// The actor must still be alive (no crash / restart during construction).
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
}
///
/// MV-7 guard: a scalar static attribute is unaffected by the List decode
/// path — it still returns its raw string value.
///
[Fact]
public void InstanceActor_StaticScalarAttribute_UnaffectedByListDecode()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-StaticScalar",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" }
]
};
var actor = CreateInstanceActor("Pump-StaticScalar", config);
actor.Tell(new GetAttributeRequest("corr-ss", "Pump-StaticScalar", "Label", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Good", response.Quality);
Assert.Equal("Main Pump", response.Value);
}
///
/// MV-7 (review fix): a SetStaticAttribute write whose value fails to decode as
/// a list (e.g. truncated JSON) on a List attribute must be REJECTED — reply
/// Success=false with a clear error and persist NOTHING. The script path always
/// pre-encodes valid JSON, but the Inbound API / direct-command path can submit
/// an arbitrary value, so a malformed value must not silently null the in-memory
/// value, publish "Good" quality, and durably persist a poison override.
///
[Fact]
public async Task InstanceActor_SetStaticListAttribute_Malformed_Rejected_NotPersisted()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-BadSet",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-BadSet", config);
// Submit a malformed list value (truncated JSON).
actor.Tell(new SetStaticAttributeCommand(
"corr-bad-set", "Pump-BadSet", "Labels", "[\"a\"", DateTimeOffset.UtcNow));
var setResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.False(setResponse.Success);
Assert.False(string.IsNullOrWhiteSpace(setResponse.ErrorMessage));
// The poison value must NOT have been persisted as a static override.
await Task.Delay(500);
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadSet");
Assert.Empty(overrides);
// A subsequent read returns the untouched config default — not the poison value.
actor.Tell(new GetAttributeRequest("corr-bad-get", "Pump-BadSet", "Labels", DateTimeOffset.UtcNow));
var getResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(getResponse.Found);
var list = Assert.IsType>(getResponse.Value);
Assert.Equal(new[] { "a", "b" }, list);
}
///
/// MV-7 (review fix): an empty-list value "[]" decodes to a non-null empty list
/// and must be accepted (NOT mistaken for a malformed value, which also decodes
/// to null). This pins the boundary between the "clearing/empty" and "poison"
/// cases that both surface as a null decode result.
///
[Fact]
public async Task InstanceActor_SetStaticListAttribute_EmptyList_Accepted()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-EmptySet",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Labels", Value = "[\"a\",\"b\"]",
DataType = "List", ElementDataType = "String"
}
]
};
var actor = CreateInstanceActor("Pump-EmptySet", config);
actor.Tell(new SetStaticAttributeCommand(
"corr-empty-set", "Pump-EmptySet", "Labels", "[]", DateTimeOffset.UtcNow));
var setResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(setResponse.Success);
actor.Tell(new GetAttributeRequest("corr-empty-get", "Pump-EmptySet", "Labels", DateTimeOffset.UtcNow));
var getResponse = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(getResponse.Found);
var list = Assert.IsType>(getResponse.Value);
Assert.Empty(list);
// The canonical JSON "[]" is persisted unchanged.
await Task.Delay(500);
var overrides = await _storage.GetStaticOverridesAsync("Pump-EmptySet");
Assert.Single(overrides);
Assert.Equal("[]", overrides["Labels"]);
}
// ── NJ-4: old-form List static override normalization on load ────────────
///
/// NJ-4: an OLD array-of-strings static override (["10","20"]) for an
/// Int32 List attribute must be re-persisted in the native form ([10,20])
/// when the actor loads it at startup. The in-memory read still returns the
/// typed list {10,20}; the on-disk value is normalized to native JSON.
///
[Fact]
public async Task InstanceActor_OldFormListOverride_NormalizedToNativeOnLoad()
{
await _storage.SetStaticOverrideAsync("Pump-OldForm", "Counts", "[\"10\",\"20\"]");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-OldForm",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-OldForm", config);
// Wait for the async override load (PipeTo) + fire-and-forget normalization.
await Task.Delay(1000);
// In-memory read returns the typed list, decoded from the old form.
actor.Tell(new GetAttributeRequest("corr-of", "Pump-OldForm", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { 10, 20 }, list);
// The on-disk override has been normalized to the native form.
var overrides = await _storage.GetStaticOverridesAsync("Pump-OldForm");
Assert.Single(overrides);
Assert.Equal("[10,20]", overrides["Counts"]);
}
///
/// NJ-4: a NATIVE-form static override ([10,20]) is already canonical, so
/// load-time normalization must be a no-op — the on-disk value is unchanged
/// (idempotent: native → native is byte-identical, so no re-persist occurs).
///
[Fact]
public async Task InstanceActor_NativeFormListOverride_NotRePersistedOnLoad()
{
await _storage.SetStaticOverrideAsync("Pump-Native", "Counts", "[10,20]");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-Native",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-Native", config);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-nat", "Pump-Native", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
var list = Assert.IsType>(response.Value);
Assert.Equal(new[] { 10, 20 }, list);
// The native value is left untouched on disk.
var overrides = await _storage.GetStaticOverridesAsync("Pump-Native");
Assert.Single(overrides);
Assert.Equal("[10,20]", overrides["Counts"]);
}
///
/// NJ-4: a scalar static override is unaffected by the List normalization path —
/// its on-disk value is left exactly as stored (no native re-encode).
///
[Fact]
public async Task InstanceActor_ScalarOverride_NotTouchedByListNormalization()
{
await _storage.SetStaticOverrideAsync("Pump-ScalarOf", "Temperature", "200.0");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-ScalarOf",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temperature", Value = "100.0", DataType = "Double" }
]
};
var actor = CreateInstanceActor("Pump-ScalarOf", config);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-sof", "Pump-ScalarOf", "Temperature", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("200.0", response.Value);
var overrides = await _storage.GetStaticOverridesAsync("Pump-ScalarOf");
Assert.Single(overrides);
Assert.Equal("200.0", overrides["Temperature"]);
}
///
/// NJ-4: a malformed stored List override (truncated JSON) must NOT crash the
/// actor and must NOT be re-persisted — it loads with Bad quality (as today),
/// the actor stays alive, and the poison on-disk value is left unchanged.
///
[Fact]
public async Task InstanceActor_MalformedListOverride_BadQuality_NotRePersisted()
{
await _storage.SetStaticOverrideAsync("Pump-BadOf", "Counts", "[\"a\"");
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump-BadOf",
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "Counts", Value = "[1,2]",
DataType = "List", ElementDataType = "Int32"
}
]
};
var actor = CreateInstanceActor("Pump-BadOf", config);
Watch(actor);
await Task.Delay(1000);
actor.Tell(new GetAttributeRequest("corr-bof", "Pump-BadOf", "Counts", DateTimeOffset.UtcNow));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.True(response.Found);
Assert.Equal("Bad", response.Quality);
Assert.Null(response.Value);
// The actor must still be alive — no crash from the normalization path.
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
// The malformed value must NOT have been re-persisted (left exactly as stored).
var overrides = await _storage.GetStaticOverridesAsync("Pump-BadOf");
Assert.Single(overrides);
Assert.Equal("[\"a\"", overrides["Counts"]);
}
}