3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
555 lines
30 KiB
C#
555 lines
30 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>Gitea #886: a native Galaxy alarm's AVEVA reference reaches the sink, bound to its condition after the
|
|
/// condition is materialised and before its notifiers are wired; a native alarm with no reference binds nothing.</summary>
|
|
[Fact]
|
|
public void MaterialiseRawSubtree_binds_a_galaxy_alarms_aveva_reference_to_its_condition()
|
|
{
|
|
var sink = new RealmRecordingSink();
|
|
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>())
|
|
{
|
|
RawTags = new[]
|
|
{
|
|
new RawTagPlan("t1", "Plant/Galaxy/Oven7/TempHi", "Plant/Galaxy/Oven7", "drv-gx", "TempHi", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("LimitAlarm", 700),
|
|
ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
|
NativeAlarmReference: "Oven_007.TempHiAlarm"),
|
|
new RawTagPlan("t2", "Plant/Modbus/plc/Hi", "Plant/Modbus/plc", "drv-mb", "Hi", "Boolean",
|
|
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500),
|
|
ReferencingEquipmentPaths: Array.Empty<string>()),
|
|
},
|
|
};
|
|
|
|
NewApplier(sink).MaterialiseRawSubtree(composition);
|
|
|
|
sink.HistoryBindings.ShouldBe(new[] { ("Plant/Galaxy/Oven7/TempHi", AddressSpaceRealm.Raw, "Oven_007.TempHiAlarm") });
|
|
sink.Calls.ShouldBe(new[] { "condition:Plant/Galaxy/Oven7/TempHi", "bind:Plant/Galaxy/Oven7/TempHi",
|
|
"wire:Plant/Galaxy/Oven7/TempHi", "condition:Plant/Modbus/plc/Hi" });
|
|
}
|
|
|
|
/// <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 List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, string Reference)> HistoryBindings { get; } = new();
|
|
public List<string> Calls { get; } = new();
|
|
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
|
{
|
|
Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
|
Calls.Add($"condition:{alarmNodeId}");
|
|
}
|
|
|
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
|
{
|
|
NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
|
|
Calls.Add($"wire:{alarmNodeId}");
|
|
}
|
|
|
|
public void BindNativeAlarmHistory(string alarmNodeId, AddressSpaceRealm alarmRealm, string alarmReference)
|
|
{
|
|
HistoryBindings.Add((alarmNodeId, alarmRealm, alarmReference));
|
|
Calls.Add($"bind:{alarmNodeId}");
|
|
}
|
|
|
|
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) { }
|
|
}
|
|
}
|