refactor(drivers): delete the dead discovered-node injection path (§8.2, #507)
#507 was filed as "injection inert in v3, re-migrate onto the raw subtree". Reading it, the retained code is not revivable: it resolves equipment from EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set). Removing the guard would have changed a log line and injected nothing. So it is deleted rather than fixed, and #507 closes as superseded by /raw browse-commit. Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost, ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver, _discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper, DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes, OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of CapturingAddressSpaceBuilder/DiscoveredNode. The connect-time discovery loop goes too (StartDiscovery, RediscoverTick, HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With injection gone it had no consumer, and leaving it would either dead-letter or keep browsing real devices up to ~15x per connect to drop the result. ITagDiscovery itself stays — the /raw browse picker drives it through Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor, so the picker is unaffected. deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real — blocked on #507". They are not: they assert an equipment-rooted graft (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped: 31 -> 13. IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses() has no production call site and nothing writes a DriverHostStatus row, but the table was re-created in the v3 initial migration, so deleting on inference would be wrong. Split to Gitea #521 with both options costed. CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described the seam as dead. Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362 passed / 4 skipped.
This commit is contained in:
-5
@@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
||||
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
||||
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
|
||||
applier.MaterialiseDiscoveredNodes(
|
||||
"eq-1",
|
||||
Array.Empty<DiscoveredFolder>(),
|
||||
new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) })
|
||||
.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------- fixtures ----------------
|
||||
|
||||
@@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class AddressSpaceApplierTests
|
||||
{
|
||||
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
|
||||
[Fact]
|
||||
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
|
||||
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
|
||||
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
|
||||
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1", "eq-2");
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
|
||||
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
||||
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
|
||||
// Each equipment torn down as a subtree.
|
||||
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
|
||||
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
|
||||
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = new AddressSpacePlan(
|
||||
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
|
||||
[Fact]
|
||||
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = new AddressSpacePlan(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: new[]
|
||||
{
|
||||
new AddressSpacePlan.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<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
|
||||
[Fact]
|
||||
public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
|
||||
{
|
||||
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1");
|
||||
|
||||
var outcome = applier.Apply(plan); // should not throw
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
|
||||
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
|
||||
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
|
||||
/// folder (decision #4).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
||||
/// folder (not the namespace root), with the variable parented to that sub-folder and a
|
||||
/// folder-scoped NodeId.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||
// A Read plan threads Writable: false (the node stays CurrentRead).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
}
|
||||
|
||||
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
||||
/// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
|
||||
/// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.VariableCalls.Count.ShouldBe(2);
|
||||
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
|
||||
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
|
||||
}
|
||||
|
||||
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag (<c>Alarm is not null</c>) materialises a
|
||||
/// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
|
||||
/// variable; a plain tag (<c>Alarm == null</c>) stays a value variable. The alarm tag's condition
|
||||
/// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's
|
||||
/// AlarmType/Severity. Proves BOTH branches in one composition.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
|
||||
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
|
||||
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
|
||||
|
||||
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
|
||||
// matching display/type/severity) and did NOT drive EnsureVariable.
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||
// A native equipment-tag alarm: the call-site threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
|
||||
sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
|
||||
}
|
||||
|
||||
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its
|
||||
/// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder),
|
||||
/// using the folder-scoped NodeId.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// The sub-folder is still created for an alarm tag with a FolderPath.
|
||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
|
||||
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
|
||||
sink.VariableCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it
|
||||
/// to <c>EnsureVariable</c>: a historized tag with NO override falls back to its <c>FullName</c>; a
|
||||
/// historized tag WITH an override passes the override verbatim; a non-historized tag passes null.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Historized, no override ⇒ tagname defaults to FullName ("T.A").
|
||||
new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null),
|
||||
// Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName.
|
||||
new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float",
|
||||
FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"),
|
||||
// Not historized ⇒ tagname is null.
|
||||
new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float",
|
||||
FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
|
||||
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
||||
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
||||
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
||||
}
|
||||
|
||||
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
|
||||
/// <c>FullName</c> (the resolve uses <c>string.IsNullOrWhiteSpace</c>, not just null).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.HistorianCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.HistorianTagname.ShouldBe("40001");
|
||||
}
|
||||
|
||||
/// <summary>Array-support Task 2 — an <see cref="EquipmentTagPlan"/> with <c>IsArray: true,
|
||||
/// ArrayLength: 16</c> flowing through <see cref="AddressSpaceApplier.MaterialiseEquipmentTags"/> must
|
||||
/// forward BOTH flags verbatim to the sink's <c>EnsureVariable</c>. Guards against arg-order swaps or
|
||||
/// accidental drops in the wire-through.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
|
||||
call.IsArray.ShouldBeTrue();
|
||||
call.ArrayLength.ShouldBe(16u);
|
||||
}
|
||||
|
||||
/// <summary>Array-support Task 2 — a scalar <see cref="EquipmentTagPlan"/> (<c>IsArray: false</c>,
|
||||
/// <c>ArrayLength: null</c>) must pass <c>isArray == false</c> through to the sink. Guards against a
|
||||
/// default flip that would silently materialise scalar tags as 1-D arrays.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.IsArray.ShouldBeFalse();
|
||||
call.ArrayLength.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review M-1 — an array equipment tag authored with <c>Writable: true</c> must be
|
||||
/// materialised as READ-ONLY (<c>writable == false</c>) because array writes are out of scope
|
||||
/// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
|
||||
/// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
|
||||
/// writable path for arrays.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Authored ReadWrite AND IsArray — the applier must clamp to read-only.
|
||||
new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeTrue();
|
||||
arrCall.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Review M-1 regression — a scalar tag authored with <c>Writable: true</c> must still
|
||||
/// be materialised as read/write (<c>writable == true</c>). The array-clamp must NOT affect
|
||||
/// scalar tags.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Authored ReadWrite, scalar — must pass through writable: true unchanged.
|
||||
new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
|
||||
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
|
||||
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
|
||||
/// the equipment folder (no sub-folder when FolderPath is empty).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||
// VirtualTags are computed outputs — always read-only (Writable: false).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
|
||||
}
|
||||
|
||||
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
||||
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
|
||||
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
|
||||
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
||||
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
||||
/// shared helper fails here.</summary>
|
||||
[Fact]
|
||||
public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
|
||||
new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
|
||||
}
|
||||
|
||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
|
||||
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty();
|
||||
sink.VariableCalls.Count.ShouldBe(2);
|
||||
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
|
||||
}
|
||||
|
||||
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
||||
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
|
||||
/// disabled alarms.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentScriptedAlarms = new[]
|
||||
{
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
|
||||
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
|
||||
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseScriptedAlarms(composition);
|
||||
|
||||
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
|
||||
// A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap).
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
|
||||
/// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
|
||||
/// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
|
||||
/// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
|
||||
/// parent-first before ensuring (a child folder's parent must exist first).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
// Child folder listed BEFORE its parent — the applier must re-order parent-first.
|
||||
var folders = new[]
|
||||
{
|
||||
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
|
||||
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
|
||||
};
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
|
||||
"String", Writable: false, IsArray: false, ArrayLength: null),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
|
||||
|
||||
// Folders ensured parent-first regardless of input order (shallowest depth first).
|
||||
sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
|
||||
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
|
||||
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
|
||||
|
||||
// Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
|
||||
sink.VariableCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
|
||||
|
||||
// Exactly one NodeAdded model-change, announced under the equipment root.
|
||||
sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — a discovered array variable (rare) authored <c>Writable: true</c> is forced
|
||||
/// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
|
||||
/// IsArray / ArrayLength flags are forwarded verbatim to the sink.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
|
||||
Writable: true, IsArray: true, ArrayLength: 8u),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), variables);
|
||||
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeTrue();
|
||||
arrCall.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
|
||||
/// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
|
||||
/// existing nodes), and a model-change is raised once PER call (twice across two calls).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var folders = new[]
|
||||
{
|
||||
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
|
||||
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
|
||||
};
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
|
||||
"String", Writable: false, IsArray: false, ArrayLength: null),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
|
||||
Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
|
||||
|
||||
// Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
|
||||
// DISTINCT set the applier produces is stable across re-applies.
|
||||
sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
|
||||
sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
|
||||
// One model-change per call ⇒ two across two calls.
|
||||
sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
|
||||
/// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), Array.Empty<DiscoveredVariable>());
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty();
|
||||
sink.VariableCalls.ShouldBeEmpty();
|
||||
sink.ModelChangeCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
|
||||
|
||||
Reference in New Issue
Block a user