52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
508 lines
28 KiB
C#
508 lines
28 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
|
|
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
|
|
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
|
|
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
|
|
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
|
|
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
|
|
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
|
|
/// its realm EXPLICITLY.
|
|
/// </summary>
|
|
public sealed class AddressSpaceApplierRawUnsTests
|
|
{
|
|
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
|
|
new(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawContainers = new[]
|
|
{
|
|
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
|
|
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
|
|
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
|
|
},
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
|
|
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
// Containers are folders in the Raw realm, parents-before-children.
|
|
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
|
|
{
|
|
("Plant", AddressSpaceRealm.Raw),
|
|
("Plant/Modbus", AddressSpaceRealm.Raw),
|
|
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
|
|
});
|
|
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
|
|
var v = sink.Variables.ShouldHaveSingleItem();
|
|
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
|
v.Parent.ShouldBe("Plant/Modbus/dev1");
|
|
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
v.Writable.ShouldBeTrue();
|
|
v.HistorianTagname.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
// Historized, no override → historian tagname defaults to the RawPath.
|
|
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
|
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
|
|
// Historized with override → the override wins.
|
|
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
|
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
var byId = sink.Variables.ToDictionary(v => v.NodeId);
|
|
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
|
|
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
|
|
}
|
|
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
|
ReferencingEquipmentPaths: Array.Empty<string>()),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
|
|
var c = sink.Conditions.ShouldHaveSingleItem();
|
|
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
|
|
c.Parent.ShouldBe("Plant/A/dev");
|
|
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
c.IsNative.ShouldBeTrue();
|
|
// No referencing equipment ⇒ no multi-notifier wiring.
|
|
sink.NotifierWirings.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
|
|
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
|
|
var composition = new AddressSpaceComposition(
|
|
new[] { new UnsAreaProjection("a1", "filling") },
|
|
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
|
|
new[]
|
|
{
|
|
new EquipmentNode("EQ-1", "station1", "l1"),
|
|
new EquipmentNode("EQ-2", "station2", "l2"),
|
|
},
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
|
// The composer emits Area/Line/Equipment NAME paths here.
|
|
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
// The single condition is materialised ONCE at the RawPath (Raw realm)...
|
|
var c = sink.Conditions.ShouldHaveSingleItem();
|
|
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
|
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
|
|
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
|
|
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
|
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
|
|
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
|
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
|
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name
|
|
/// coupling). The composer builds a native alarm's <c>ReferencingEquipmentPaths</c> from the
|
|
/// Area/Line/Equipment <c>Name</c>s; the applier's <c>BuildEquipmentIdByFolderPath</c> inverts those
|
|
/// paths back to the <c>EquipmentId</c> via the projected DisplayName. Feed a REAL composer output
|
|
/// (not a hand-built plan) through <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> and assert
|
|
/// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree
|
|
/// end-to-end. If a future editable Area/Line display-name feature made <c>DisplayName != Name</c>, this
|
|
/// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier()
|
|
{
|
|
// Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" /
|
|
// Line "line-1" / Equipment "station-1").
|
|
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
|
var driver = new DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
|
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
|
};
|
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
|
var speed = new Tag
|
|
{
|
|
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
|
|
AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
|
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
|
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
|
|
|
var composition = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equip },
|
|
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: new[] { refSpeed },
|
|
tags: new[] { speed },
|
|
rawFolders: new[] { folder },
|
|
devices: new[] { device },
|
|
tagGroups: Array.Empty<TagGroup>());
|
|
|
|
// The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names.
|
|
var alarmTag = composition.RawTags.Single(t => t.Alarm is not null);
|
|
alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") });
|
|
|
|
// The applier inverts that path back to the EquipmentId folder and wires the notifier there.
|
|
var sink = new RealmRecordingSink();
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
|
w.AlarmNodeId.ShouldBe(alarmTag.NodeId);
|
|
w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId
|
|
}
|
|
|
|
/// <summary>Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an
|
|
/// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed
|
|
/// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the
|
|
/// condition itself is still materialised.</summary>
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
// No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder.
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
|
ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }),
|
|
},
|
|
};
|
|
|
|
var failed = NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip)
|
|
sink.NotifierWirings.ShouldBeEmpty(); // nothing wired
|
|
sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised
|
|
}
|
|
|
|
[Fact]
|
|
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
|
|
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
|
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
|
IsHistorized: true, HistorianTagname: "WW.Speed"),
|
|
},
|
|
UnsReferenceVariables = new[]
|
|
{
|
|
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
|
"Plant/A/dev/speed", "Float", Writable: true,
|
|
BackingIsHistorized: true, BackingHistorianTagname: "WW.Speed"),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseUnsReferences(composition);
|
|
|
|
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
|
|
var v = sink.Variables.ShouldHaveSingleItem();
|
|
v.NodeId.ShouldBe("filling/line1/station1/speed");
|
|
v.Parent.ShouldBe("EQ-1");
|
|
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
|
v.Writable.ShouldBeTrue();
|
|
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
|
|
|
|
// An Organizes reference UNS→Raw links the two.
|
|
var r = sink.References.ShouldHaveSingleItem();
|
|
r.Source.ShouldBe("filling/line1/station1/speed");
|
|
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
|
|
r.Target.ShouldBe("Plant/A/dev/speed");
|
|
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
|
|
r.ReferenceType.ShouldBe("Organizes");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-15 review — the inherited historian tagname + array shape come from the
|
|
/// <see cref="UnsReferenceVariable"/> RECORD, never from a <c>RawTags</c> back-lookup by RawPath. The
|
|
/// fixture makes the two disagree DELIBERATELY (the raw tag says de-historized + scalar; the reference
|
|
/// record says historized "WW.Record" + array-of-4) so the assertion can only pass on the record. That
|
|
/// matters because the record is what the planner diffed to produce the repair delta: materialising
|
|
/// from anything else would recreate the node with values nobody diffed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MaterialiseUnsReferences_inherits_shape_and_historian_from_the_record_not_the_raw_tag()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
|
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
|
IsHistorized: false, HistorianTagname: "WW.RawTag", IsArray: false, ArrayLength: null),
|
|
},
|
|
UnsReferenceVariables = new[]
|
|
{
|
|
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
|
"Plant/A/dev/speed", "Float", Writable: true,
|
|
BackingIsHistorized: true, BackingHistorianTagname: "WW.Record",
|
|
BackingIsArray: true, BackingArrayLength: 4),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseUnsReferences(composition);
|
|
|
|
var v = sink.Variables.ShouldHaveSingleItem();
|
|
v.HistorianTagname.ShouldBe("WW.Record"); // the record wins; the raw tag says not historized at all
|
|
v.IsArray.ShouldBeTrue();
|
|
v.ArrayLength.ShouldBe(4u);
|
|
v.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array mirror is read-only
|
|
}
|
|
|
|
/// <summary>Gitea #610: an authored-ReadWrite ARRAY materialises writable on the raw node AND its UNS mirror
|
|
/// exactly when the driver can write arrays (<c>ArrayWritable</c> / <c>BackingArrayWritable</c>), and
|
|
/// read-only otherwise; an authored-Read array stays read-only either way.</summary>
|
|
[Theory]
|
|
[InlineData(true, true, true)]
|
|
[InlineData(true, false, false)]
|
|
[InlineData(false, true, false)]
|
|
public void Array_nodes_are_writable_only_when_the_driver_can_write_arrays(bool authoredWritable, bool arrayWritable, bool expected)
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/A/dev/levels", "Plant/A/dev", "drv", "levels", "Int16",
|
|
Writable: authoredWritable, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
|
IsArray: true, ArrayLength: 4, ArrayWritable: arrayWritable),
|
|
},
|
|
UnsReferenceVariables = new[]
|
|
{
|
|
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/levels", "levels",
|
|
"Plant/A/dev/levels", "Int16", Writable: authoredWritable,
|
|
BackingIsArray: true, BackingArrayLength: 4, BackingArrayWritable: arrayWritable),
|
|
},
|
|
};
|
|
|
|
var applier = NewApplier(sink);
|
|
applier.MaterialiseRawSubtree(composition);
|
|
applier.MaterialiseUnsReferences(composition);
|
|
|
|
sink.Variables.Count.ShouldBe(2);
|
|
sink.Variables.ShouldAllBe(v => v.Writable == expected && v.IsArray);
|
|
}
|
|
|
|
/// <summary>The historian-resolution semantics carried over verbatim from the old back-lookup: a BLANK
|
|
/// <c>BackingHistorianTagname</c> on a historized reference falls back to the <c>BackingRawPath</c> (which
|
|
/// IS the backing raw tag's NodeId, the raw side's own default), and a non-historized reference registers
|
|
/// no tagname at all even when an override string is present.</summary>
|
|
[Theory]
|
|
[InlineData(true, null, "Plant/A/dev/speed")]
|
|
[InlineData(true, " ", "Plant/A/dev/speed")]
|
|
[InlineData(true, "WW.Override", "WW.Override")]
|
|
[InlineData(false, "WW.Override", null)]
|
|
public void MaterialiseUnsReferences_resolves_the_historian_tagname_like_the_raw_side(
|
|
bool historized, string? tagname, string? expected)
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
UnsReferenceVariables = new[]
|
|
{
|
|
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
|
"Plant/A/dev/speed", "Float", Writable: true,
|
|
BackingIsHistorized: historized, BackingHistorianTagname: tagname),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseUnsReferences(composition);
|
|
|
|
sink.Variables.ShouldHaveSingleItem().HistorianTagname.ShouldBe(expected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-15 surgical-deploys (Task 6) — the subset overload materialises ONLY the tags/containers it
|
|
/// is handed, while still resolving native-alarm notifier targets from the FULL composition's equipment
|
|
/// map. Both halves matter: narrowing the iteration is the whole point (a one-tag deploy used to re-walk
|
|
/// every node on the server), and the map has to stay full because the equipment a raw alarm fans to is
|
|
/// not part of any raw-tag delta — building it from the subset would silently drop the fan-out.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_with_a_subset_ensures_only_the_subset_but_resolves_notifiers_from_the_full_composition()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var untouched = new RawTagPlan("t0", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
|
Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
|
|
var added = new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
|
ReferencingEquipmentPaths: new[] { "filling/line1/station1" });
|
|
var composition = new AddressSpaceComposition(
|
|
new[] { new UnsAreaProjection("a1", "filling") },
|
|
new[] { new UnsLineProjection("l1", "a1", "line1") },
|
|
new[] { new EquipmentNode("EQ-1", "station1", "l1") },
|
|
Array.Empty<DriverInstancePlan>(),
|
|
Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawContainers = new[]
|
|
{
|
|
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
|
|
new RawContainerNode("Plant/A", "Plant", "A", RawNodeKind.Driver),
|
|
new RawContainerNode("Plant/A/dev", "Plant/A", "dev", RawNodeKind.Device),
|
|
},
|
|
RawTags = new[] { untouched, added },
|
|
};
|
|
|
|
// The delta of a one-tag add: no new containers, exactly one new tag.
|
|
var failed = NewApplier(sink).MaterialiseRawSubtree(
|
|
composition,
|
|
onlyContainers: Array.Empty<RawContainerNode>(),
|
|
onlyTags: new[] { added });
|
|
|
|
failed.ShouldBe(0);
|
|
sink.Folders.ShouldBeEmpty(); // the three existing containers were NOT re-ensured
|
|
sink.Variables.ShouldBeEmpty(); // the untouched value tag was NOT re-ensured
|
|
sink.Conditions.ShouldHaveSingleItem().NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
|
// …and the notifier still resolved through the FULL composition's Area/Line/Equipment map.
|
|
sink.NotifierWirings.ShouldHaveSingleItem().FolderNodeIds.ShouldBe(new[] { "EQ-1" });
|
|
}
|
|
|
|
/// <summary>A null subset (the rebuild path) still means "the whole composition" — the overload is additive
|
|
/// and every pre-existing call site keeps its behaviour.</summary>
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_with_no_subset_still_materialises_the_whole_composition()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
RawContainers = new[] { new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder) },
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t0", "Plant/a", "Plant", "drv", "a", "Float", Writable: true, Alarm: null,
|
|
ReferencingEquipmentPaths: Array.Empty<string>()),
|
|
new RawTagPlan("t1", "Plant/b", "Plant", "drv", "b", "Float", Writable: true, Alarm: null,
|
|
ReferencingEquipmentPaths: Array.Empty<string>()),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
sink.Folders.Select(f => f.NodeId).ShouldBe(new[] { "Plant" });
|
|
sink.Variables.Select(v => v.NodeId).ShouldBe(new[] { "Plant/a", "Plant/b" });
|
|
}
|
|
|
|
/// <summary>2026-08-15 surgical-deploys (Task 6) — the UNS-reference subset overload materialises only the
|
|
/// references it is handed (with their Organizes edges), leaving every other reference in the composition
|
|
/// untouched.</summary>
|
|
[Fact]
|
|
public void MaterialiseUnsReferences_with_a_subset_ensures_only_the_subset()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
var untouched = new UnsReferenceVariable("r0", "EQ-1", "filling/line1/station1/speed", "speed",
|
|
"Plant/A/dev/speed", "Float", Writable: true);
|
|
var added = new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/temp", "temp",
|
|
"Plant/A/dev/temp", "Float", Writable: false);
|
|
var composition = new AddressSpaceComposition(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
UnsReferenceVariables = new[] { untouched, added },
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseUnsReferences(composition, only: new[] { added });
|
|
|
|
sink.Variables.ShouldHaveSingleItem().NodeId.ShouldBe("filling/line1/station1/temp");
|
|
sink.References.ShouldHaveSingleItem().Target.ShouldBe("Plant/A/dev/temp");
|
|
}
|
|
|
|
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
|
|
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
|
|
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm, bool IsArray, uint? ArrayLength)> Variables { get; } = new();
|
|
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
|
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
|
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
|
|
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
|
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
|
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
|
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
|
|
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
|
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
|
|
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
|
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
|
|
|
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
|
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
|
|
|
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
public void RebuildAddressSpace() { }
|
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
|
}
|
|
}
|