feat(opcua): F14 Phase7Plan + Phase7Applier
Some checks failed
v2-ci / build (push) Failing after 34s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Some checks failed
v2-ci / build (push) Failing after 34s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Splits the side-effecting half of Phase7Composer (deferred at Task 47) into two pieces that mirror DriverHostActor's spawn-plan pattern: Phase7Plan + Phase7Planner.Compute (pure): Diff two Phase7CompositionResult snapshots by stable id (EquipmentId, DriverInstanceId, ScriptedAlarmId). Emits Added/Removed/Changed lists per entity class. Added/Removed are sorted by id for deterministic apply order. Changed wraps both Previous and Current projections so consumers can decide between in-place mutation and tear-down + rebuild. Phase7Applier (side-effecting): Drives an IOpcUaAddressSpaceSink against a plan. Removed equipment/ alarms get an inactive AlarmState write per id; Added/Removed of Equipment or ScriptedAlarm triggers RebuildAddressSpace. Driver-only changes correctly skip the rebuild — those flow through DriverHost- Actor's spawn-plan in Runtime. Sink exceptions are caught + logged so one bad node doesn't abort the apply. Tests: OpcUaServer 6 -> 20 (+14): - Phase7PlannerTests x9 (empty-in/empty-out, add/remove/change per entity class, mixed changes, deterministic ordering) - Phase7ApplierTests x5 (empty plan no-op, removal writes inactive states + rebuild, added equipment triggers rebuild, driver-only skips rebuild, sink fault is non-fatal) The remaining piece is the EquipmentNodeWalker integration against a real SDK NodeManager — split as F14b, gated on F10b's SDK builder. All 6 v2 test suites green: 146 tests passing.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Phase7ApplierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(EmptyPlan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
outcome.AddedNodes.ShouldBe(0);
|
||||
outcome.RemovedNodes.ShouldBe(0);
|
||||
outcome.ChangedNodes.ShouldBe(0);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1", "eq-2");
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
||||
sink.AlarmWrites.All(a => a.Active == false && a.Acknowledged == false).ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_equipment_triggers_rebuild_without_alarm_writes()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var plan = new Phase7Plan(
|
||||
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var plan = new Phase7Plan(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: new[]
|
||||
{
|
||||
new Phase7Plan.DriverDelta(
|
||||
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
|
||||
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
|
||||
},
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
|
||||
{
|
||||
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1");
|
||||
|
||||
var outcome = applier.Apply(plan); // should not throw
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static Phase7Plan EmptyPlan => new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
||||
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>());
|
||||
|
||||
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
|
||||
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
|
||||
public int RebuildCalls;
|
||||
|
||||
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private readonly bool _throwOnAlarmWrite;
|
||||
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
|
||||
{
|
||||
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
|
||||
}
|
||||
public void RebuildAddressSpace() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Phase7PlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_inputs_produce_empty_plan()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = prev;
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_compositions_produce_empty_plan()
|
||||
{
|
||||
var eq = new EquipmentNode("eq-1", "Eq 1", "line-1");
|
||||
var prev = new Phase7CompositionResult(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_equipment_goes_to_AddedEquipment()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-1", "A", "line-1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-1");
|
||||
plan.RemovedEquipment.ShouldBeEmpty();
|
||||
plan.ChangedEquipment.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disappeared_equipment_goes_to_RemovedEquipment()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-1", "A", "line-1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-1");
|
||||
plan.AddedEquipment.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_id_with_different_display_name_routes_to_ChangedEquipment()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-1", "Old", "line-1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-1", "New", "line-1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.ChangedEquipment.Single().Previous.DisplayName.ShouldBe("Old");
|
||||
plan.ChangedEquipment.Single().Current.DisplayName.ShouldBe("New");
|
||||
plan.AddedEquipment.ShouldBeEmpty();
|
||||
plan.RemovedEquipment.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_config_change_routes_to_ChangedDrivers()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"old\"}") },
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"new\"}") },
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.ChangedDrivers.Single().Current.ConfigJson.ShouldContain("new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alarm_message_template_change_routes_to_ChangedAlarms()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "old") });
|
||||
var next = new Phase7CompositionResult(
|
||||
Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "new") });
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.ChangedAlarms.Single().Current.MessageTemplate.ShouldBe("new");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_and_removed_lists_are_sorted_by_id_for_deterministic_ordering()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("z", "Z", "line-1"), new EquipmentNode("a", "A", "line-1") },
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new Phase7CompositionResult(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass()
|
||||
{
|
||||
var prev = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-drop", "Drop", "line-1") },
|
||||
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":1}") },
|
||||
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1") });
|
||||
var next = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-new", "New", "line-1") },
|
||||
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":2}") },
|
||||
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1"), new ScriptedAlarmPlan("a-new", "eq-new", "s2", "t2") });
|
||||
|
||||
var plan = Phase7Planner.Compute(prev, next);
|
||||
|
||||
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-new");
|
||||
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-drop");
|
||||
plan.ChangedEquipment.ShouldBeEmpty();
|
||||
plan.ChangedDrivers.Single().Current.DriverInstanceId.ShouldBe("drv-change");
|
||||
plan.AddedAlarms.Single().ScriptedAlarmId.ShouldBe("a-new");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user