test(otopcua): end-to-end discovered-node injection + value flow
This commit is contained in:
+348
@@ -0,0 +1,348 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the
|
||||||
|
/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites
|
||||||
|
/// (which wire the OPC UA publish side as a <see cref="Akka.TestKit.TestProbe"/> and assert on the
|
||||||
|
/// intercepted <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> /
|
||||||
|
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> messages), this suite wires the FULL real chain:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>a real <see cref="DriverHostActor"/> (resolves equipment, maps via
|
||||||
|
/// <see cref="DiscoveredNodeMapper"/>, extends the live-value routing map, caches the plan);</item>
|
||||||
|
/// <item>a real <see cref="OpcUaPublishActor"/> as its <c>opcUaPublishActor</c> seam (so
|
||||||
|
/// <c>MaterialiseDiscoveredNodes</c> + <c>AttributeValueUpdate</c> are actually handled, not
|
||||||
|
/// intercepted);</item>
|
||||||
|
/// <item>a real <see cref="AddressSpaceApplier"/> over a recording
|
||||||
|
/// <see cref="IOpcUaAddressSpaceSink"/> (so the materialise + value-write reach the sink).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The assertions are therefore made on the SINK's recorded <c>EnsureVariable</c> /
|
||||||
|
/// <c>RaiseNodesAddedModelChange</c> / <c>WriteValue</c> calls — i.e. the discovered node was
|
||||||
|
/// materialised through the real applier AND a published value surfaces <see cref="OpcUaQuality.Good"/>
|
||||||
|
/// at the mapped NodeId (in production this overwrites the <c>BadWaitingForInitialData</c> seed that
|
||||||
|
/// <c>OtOpcUaNodeManager.EnsureVariable</c> stamps on a freshly-materialised variable; the recording
|
||||||
|
/// sink does not model that seed, so the faithful assertion available here is that the live value
|
||||||
|
/// lands Good at the same NodeId the materialise created).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Seam choices (faithful to the sibling suites).</b> Discovery is driven by Telling the host
|
||||||
|
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> directly, and the polled value by Telling
|
||||||
|
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> directly — exactly the seams the Task-7/8
|
||||||
|
/// tests use (there is no test seam to drive a real <see cref="ITagDiscovery"/> poll loop through a
|
||||||
|
/// child to Connected, and the spawned child is a <see cref="SubscribableStubDriver"/>). The publish
|
||||||
|
/// actor is wired WITHOUT a dbFactory, so the host's apply-time <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
|
||||||
|
/// falls back to a raw <c>sink.RebuildAddressSpace()</c> (no <c>EnsureVariable</c>); this keeps the
|
||||||
|
/// ONLY <c>EnsureVariable</c> traffic on the sink the discovered-node materialise itself, so the
|
||||||
|
/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing
|
||||||
|
/// map) is fully real.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node");
|
||||||
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||||
|
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
// The FixedTree node the driver "discovers": FOCAS/<deviceHost>/Identity/SeriesNumber, a String value,
|
||||||
|
// whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored
|
||||||
|
// node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber.
|
||||||
|
private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber";
|
||||||
|
private const string FixedTreeDisplayName = "SeriesNumber";
|
||||||
|
|
||||||
|
private static DiscoveredNode[] FixedTreeNodes() => new[]
|
||||||
|
{
|
||||||
|
new DiscoveredNode(
|
||||||
|
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
|
||||||
|
BrowseName: "SeriesNumber",
|
||||||
|
DisplayName: FixedTreeDisplayName,
|
||||||
|
FullReference: FixedTreeRef,
|
||||||
|
DataType: DriverDataType.String,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
Writable: false,
|
||||||
|
IsHistorized: false),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows
|
||||||
|
/// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a
|
||||||
|
/// real publish actor + real applier + recording sink, then asserts:
|
||||||
|
/// (a) the sink recorded an <c>EnsureVariable</c> for the FixedTree node under EQ-1 (materialised
|
||||||
|
/// through the REAL applier — the node now exists in the served address space), with a
|
||||||
|
/// <c>RaiseNodesAddedModelChange</c> under EQ-1 so connected clients refresh;
|
||||||
|
/// (b) after an <see cref="DriverInstanceActor.AttributeValuePublished"/> for the FixedTree ref, the
|
||||||
|
/// sink recorded a <c>WriteValue</c> at THAT SAME NodeId carrying the value with
|
||||||
|
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
|
||||||
|
/// overwrote the BadWaitingForInitialData seed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||||
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
|
||||||
|
var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId);
|
||||||
|
|
||||||
|
// Driver reports its captured FixedTree (the faithful Task-7/8 seam).
|
||||||
|
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
|
||||||
|
|
||||||
|
// (a) The discovered variable was materialised through the REAL applier onto the sink, under EQ-1.
|
||||||
|
string fixedTreeNodeId = null!;
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
|
||||||
|
v.NodeId.ShouldNotBeNull(); // EnsureVariable recorded for the FixedTree node
|
||||||
|
v.NodeId.ShouldStartWith("EQ-1"); // grafted UNDER the bound equipment root
|
||||||
|
v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink
|
||||||
|
v.Writable.ShouldBeFalse(); // discovered nodes are read-only
|
||||||
|
fixedTreeNodeId = v.NodeId;
|
||||||
|
sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment
|
||||||
|
}, duration: Timeout);
|
||||||
|
|
||||||
|
// (b) A value published for the FixedTree ref routes to the mapped NodeId and lands Good — the live
|
||||||
|
// value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue).
|
||||||
|
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var write = sink.Values.SingleOrDefault(x => x.NodeId == fixedTreeNodeId);
|
||||||
|
write.NodeId.ShouldBe(fixedTreeNodeId);
|
||||||
|
write.Value.ShouldBe("SN-12345");
|
||||||
|
write.Quality.ShouldBe(OpcUaQuality.Good);
|
||||||
|
write.Ts.ShouldBe(Ts);
|
||||||
|
}, duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a
|
||||||
|
/// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new
|
||||||
|
/// revision, same d1 → EQ-1 binding) re-runs <c>PushDesiredSubscriptions</c> — which clears the
|
||||||
|
/// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts
|
||||||
|
/// the cached discovered plan. Asserts the sink records the FixedTree <c>EnsureVariable</c> AGAIN
|
||||||
|
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
|
||||||
|
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Discovered_node_and_value_survive_a_redeploy()
|
||||||
|
{
|
||||||
|
var db = NewInMemoryDbFactory();
|
||||||
|
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||||
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
|
||||||
|
var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId);
|
||||||
|
|
||||||
|
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
|
||||||
|
|
||||||
|
// First injection: capture the mapped NodeId once it has materialised on the sink.
|
||||||
|
string fixedTreeNodeId = null!;
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
|
||||||
|
v.NodeId.ShouldNotBeNull();
|
||||||
|
fixedTreeNodeId = v.NodeId;
|
||||||
|
}, duration: Timeout);
|
||||||
|
|
||||||
|
// First value flows Good (pre-redeploy baseline).
|
||||||
|
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts));
|
||||||
|
AwaitAssert(
|
||||||
|
() => sink.Values.ShouldContain(x => x.NodeId == fixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
|
||||||
|
duration: Timeout);
|
||||||
|
|
||||||
|
var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == fixedTreeNodeId);
|
||||||
|
|
||||||
|
// Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions
|
||||||
|
// (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan.
|
||||||
|
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
|
||||||
|
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
||||||
|
host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
|
// (a) The cached discovered plan was RE-MATERIALISED at the SAME NodeId after the redeploy rebuild.
|
||||||
|
AwaitAssert(
|
||||||
|
() => sink.Variables.Count(x => x.NodeId == fixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
|
||||||
|
duration: Timeout);
|
||||||
|
|
||||||
|
// (b) A value published AFTER the redeploy STILL routes to the mapped NodeId and lands Good — the
|
||||||
|
// live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it).
|
||||||
|
var tsAfter = Ts.AddSeconds(5);
|
||||||
|
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter));
|
||||||
|
AwaitAssert(
|
||||||
|
() => sink.Values.ShouldContain(x => x.NodeId == fixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
|
||||||
|
duration: Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spawns the real chain — recording sink → real <see cref="AddressSpaceApplier"/> → real
|
||||||
|
/// <see cref="OpcUaPublishActor"/> (the host's <c>opcUaPublishActor</c> seam) → real
|
||||||
|
/// <see cref="DriverHostActor"/> backed by a <see cref="SubscribableStubDriver"/> — dispatches the
|
||||||
|
/// deployment, and waits for the Applied ACK so <c>_lastComposition</c> + the live child + the initial
|
||||||
|
/// subscribe pass have completed before discovery is injected. The publish actor is wired with the
|
||||||
|
/// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable)
|
||||||
|
/// and the only EnsureVariable traffic is the discovered-node materialise itself.</summary>
|
||||||
|
private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||||
|
{
|
||||||
|
var coordinator = CreateTestProbe();
|
||||||
|
var vtHost = CreateTestProbe();
|
||||||
|
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||||
|
var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||||
|
|
||||||
|
var host = Sys.ActorOf(DriverHostActor.Props(
|
||||||
|
db, TestNode, coordinator.Ref,
|
||||||
|
driverFactory: new SubscribingDriverFactory("Modbus"),
|
||||||
|
localRoles: new HashSet<string> { "driver" },
|
||||||
|
opcUaPublishActor: publish,
|
||||||
|
virtualTagHostOverride: vtHost.Ref));
|
||||||
|
|
||||||
|
host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||||
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||||
|
|
||||||
|
return (host, sink, coordinator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project
|
||||||
|
/// equipment tags + a real (non-stubbed) <see cref="DriverInstanceActor"/> child for each driver
|
||||||
|
/// (mirrors <c>DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags</c>). An authored value tag
|
||||||
|
/// both sets <c>_lastComposition</c> and binds the driver → equipment (the only way the host resolves the
|
||||||
|
/// equipment a discovered node grafts under).</summary>
|
||||||
|
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
||||||
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||||
|
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
||||||
|
{
|
||||||
|
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
||||||
|
|
||||||
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Namespaces = new[]
|
||||||
|
{
|
||||||
|
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||||
|
},
|
||||||
|
DriverInstances = driverIds.Select(d => new
|
||||||
|
{
|
||||||
|
DriverInstanceRowId = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = d,
|
||||||
|
Name = d,
|
||||||
|
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||||
|
Enabled = true,
|
||||||
|
DriverConfig = "{}",
|
||||||
|
NamespaceId = "ns-eq",
|
||||||
|
}).ToArray(),
|
||||||
|
Tags = tags.Select((t, i) => new
|
||||||
|
{
|
||||||
|
TagId = $"tag-{i}",
|
||||||
|
EquipmentId = t.Equip,
|
||||||
|
DriverInstanceId = t.Driver,
|
||||||
|
Name = t.Name,
|
||||||
|
FolderPath = t.Folder,
|
||||||
|
DataType = "Double",
|
||||||
|
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
||||||
|
}).ToArray(),
|
||||||
|
});
|
||||||
|
|
||||||
|
var id = DeploymentId.NewId();
|
||||||
|
using var ctx = db.CreateDbContext();
|
||||||
|
ctx.Deployments.Add(new Deployment
|
||||||
|
{
|
||||||
|
DeploymentId = id.Value,
|
||||||
|
RevisionHash = rev.Value,
|
||||||
|
Status = DeploymentStatus.Sealed,
|
||||||
|
CreatedBy = "test",
|
||||||
|
SealedAtUtc = DateTime.UtcNow,
|
||||||
|
ArtifactBlob = artifact,
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
|
||||||
|
/// type, so a real (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the driver and
|
||||||
|
/// the host's subscribe path is exercised (mirrors
|
||||||
|
/// <c>DriverHostActorDiscoveryTests.SubscribingDriverFactory</c>).</summary>
|
||||||
|
private sealed class SubscribingDriverFactory : IDriverFactory
|
||||||
|
{
|
||||||
|
private readonly string _supportedType;
|
||||||
|
private readonly SubscribableStubDriver _driver = new();
|
||||||
|
public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
|
||||||
|
string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recording <see cref="IOpcUaAddressSpaceSink"/> — captures the EnsureFolder / EnsureVariable /
|
||||||
|
/// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can
|
||||||
|
/// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the
|
||||||
|
/// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread).</summary>
|
||||||
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new();
|
||||||
|
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new();
|
||||||
|
private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new();
|
||||||
|
private readonly ConcurrentQueue<string> _modelChanges = new();
|
||||||
|
|
||||||
|
/// <summary>Gets a snapshot of the recorded EnsureFolder calls.</summary>
|
||||||
|
public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList();
|
||||||
|
/// <summary>Gets a snapshot of the recorded EnsureVariable calls.</summary>
|
||||||
|
public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList();
|
||||||
|
/// <summary>Gets a snapshot of the recorded WriteValue calls.</summary>
|
||||||
|
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList();
|
||||||
|
/// <summary>Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements.</summary>
|
||||||
|
public List<string> ModelChanges => _modelChanges.ToList();
|
||||||
|
/// <summary>Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback).</summary>
|
||||||
|
public int RebuildCalls;
|
||||||
|
|
||||||
|
/// <summary>Records a live-value write.</summary>
|
||||||
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
|
||||||
|
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
||||||
|
|
||||||
|
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
||||||
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||||
|
|
||||||
|
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
||||||
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
||||||
|
|
||||||
|
/// <summary>Records an EnsureFolder call.</summary>
|
||||||
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
|
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||||
|
|
||||||
|
/// <summary>Records an EnsureVariable call.</summary>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||||
|
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||||
|
|
||||||
|
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
|
||||||
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
|
|
||||||
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||||
|
public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user