using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy: /// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove). /// Surgical deploys (2026-08-15 Task 4) narrowed the Changed sets: a NON-alarm raw-tag delta whose /// only differences are the in-place-updatable attributes and/or its ReferencingEquipmentPaths is /// surgical (NOT a rebuild), and every UNS-reference delta is applied remove+recreate. Alarm-bearing and /// identity-field raw-tag deltas still route to Rebuild — the default-closed WP4 notifier guard. /// public sealed class AddressSpaceChangeClassifierDualNamespaceTests { private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder); private static RawTagPlan RawTag(string nodeId) => new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float", Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()); private static UnsReferenceVariable UnsRef(string nodeId) => new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false); private static AddressSpacePlan Empty() => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); [Fact] public void Added_raw_tag_only_is_PureAdd() { var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Added_raw_container_only_is_PureAdd() { var plan = Empty() with { AddedRawContainers = new[] { Folder } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Added_uns_reference_only_is_PureAdd() { var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } [Fact] public void Removed_raw_tag_only_is_PureRemove() { var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } [Fact] public void Removed_uns_reference_only_is_PureRemove() { var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); } [Fact] public void Raw_rename_add_and_remove_is_AddRemoveMix() { var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") }, AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix); } /// The headline surgical case: adding/dropping a UnsTagReference flips the BACKING raw tag /// to Changed (ReferencingEquipmentPaths is part of RawTagPlan's equality). For a non-alarm VALUE tag that /// set is node-inert — it only drives native-alarm notifier wiring — so it must NOT tear down the world. /// (Was Changed_raw_tag_is_Rebuild; deliberately re-pointed by 2026-08-15 Task 4.) [Fact] public void Reference_only_raw_tag_delta_is_not_a_rebuild() { var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta( RawTag("Plant/Modbus1/PLC-A/Speed"), RawTag("Plant/Modbus1/PLC-A/Speed") with { ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" }, }), }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } /// The in-place-updatable attribute whitelist (Writable / IsHistorized / HistorianTagname / /// DataType / IsArray / ArrayLength / ArrayWritable) on a non-alarm raw tag is surgical, not a rebuild. [Theory] [InlineData("writable")] [InlineData("historized")] [InlineData("historianTagname")] [InlineData("dataType")] [InlineData("array")] [InlineData("arrayLength")] [InlineData("arrayWritable")] public void Attribute_raw_tag_delta_is_not_a_rebuild(string field) { var baseline = RawTag("Plant/Modbus1/PLC-A/Speed"); // ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength // in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array. // ArrayWritable (#610) likewise moves on an already-array tag (a driver-type or element-type edit). var prev = field is "arrayLength" or "arrayWritable" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline; var cur = field switch { "writable" => prev with { Writable = true }, "historized" => prev with { IsHistorized = true }, "historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" }, "dataType" => prev with { DataType = "Double" }, "array" => prev with { IsArray = true, ArrayLength = 8 }, "arrayLength" => prev with { ArrayLength = 16 }, "arrayWritable" => prev with { ArrayWritable = true }, _ => throw new ArgumentOutOfRangeException(nameof(field)), }; var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } /// WP4 binding guard: a raw tag carrying a native alarm on EITHER side still rebuilds — the /// rebuild is what clears + re-wires the per-equipment alarm notifiers. [Theory] [InlineData(true, false)] [InlineData(false, true)] [InlineData(true, true)] public void Alarm_bearing_raw_tag_delta_still_rebuilds(bool alarmBefore, bool alarmAfter) { var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700); var prev = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = alarmBefore ? alarm : null }; var cur = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = alarmAfter ? alarm : null, ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" }, }; var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } /// WP4 binding guard, CONTENT edition: an alarm present on BOTH sides whose severity (or type) /// moved still rebuilds. The null-check pair alone does not decide it — the with-equality whitelist has to /// leave Alarm out, so the two values compare unequal. [Fact] public void Alarm_content_only_raw_tag_delta_still_rebuilds() { var prev = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 500) }; var cur = prev with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 900) }; var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } /// The QUANTIFIER: the rebuild gate is .Any(d => !eligible(d)), so ONE ineligible raw-tag /// delta must rebuild even when every other delta in the same plan is surgical. Pins against a regression /// to .All(eligible) on the wrong side, or to inspecting only First() — either of which would /// apply an identity/alarm change as an in-place attribute update and leave the node mis-wired. [Fact] public void One_ineligible_raw_tag_delta_among_eligible_ones_still_rebuilds() { var eligiblePrev = RawTag("Plant/Modbus1/PLC-A/Speed"); var ineligiblePrev = RawTag("Plant/Modbus1/PLC-A/Temp") with { TagId = "t-2" }; var plan = Empty() with { ChangedRawTags = new[] { // Surgical: a plain historize toggle. Listed FIRST so a First()-only check would pass it. new AddressSpacePlan.RawTagDelta(eligiblePrev, eligiblePrev with { IsHistorized = true }), // Ineligible: DriverInstanceId re-routes the node to a different driver point. new AddressSpacePlan.RawTagDelta(ineligiblePrev, ineligiblePrev with { DriverInstanceId = "drv-2" }), }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } /// Default-closed: an identity field outside the whitelist (DriverInstanceId re-routes the node to /// another driver point; Name/TagId/ParentNodeId re-shape identity) still forces a full rebuild. [Theory] [InlineData("driver")] [InlineData("name")] [InlineData("tagId")] [InlineData("parent")] public void Identity_field_raw_tag_delta_still_rebuilds(string field) { var prev = RawTag("Plant/Modbus1/PLC-A/Speed"); var cur = field switch { "driver" => prev with { DriverInstanceId = "drv-2" }, "name" => prev with { Name = "Velocity" }, "tagId" => prev with { TagId = "t-2" }, "parent" => prev with { ParentNodeId = "Plant/Modbus1/PLC-B" }, _ => throw new ArgumentOutOfRangeException(nameof(field)), }; var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } /// A UNS-reference re-point (BackingRawPath moved) is applied surgically (remove-previous + /// recreate-current), NOT as a rebuild. (Was Uns_reference_repoint_is_Rebuild; deliberately /// re-pointed by 2026-08-15 Task 4.) [Fact] public void Uns_reference_repoint_delta_is_not_a_rebuild() { var plan = Empty() with { ChangedUnsReferenceVariables = new[] { new AddressSpacePlan.UnsReferenceDelta( UnsRef("filling/line-1/station-1/Speed"), UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }), }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } /// The other UnsReferenceDelta variant — a display-name override edit keeps the row id while the /// NodeId + EffectiveName change. Also surgical (remove-previous + recreate-current). [Fact] public void Uns_reference_display_name_delta_is_not_a_rebuild() { var plan = Empty() with { ChangedUnsReferenceVariables = new[] { new AddressSpacePlan.UnsReferenceDelta( UnsRef("filling/line-1/station-1/Speed"), UnsRef("filling/line-1/station-1/LineSpeed") with { EffectiveName = "LineSpeed" }), }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); } /// The headline end-to-end shape: adding ONE UNS reference produces an added UNS variable PLUS the /// induced reference-only delta on the backing raw tag. That must classify PureAdd — one node created, /// zero teardown, every client subscription server-wide survives. [Fact] public void Uns_reference_add_with_induced_reference_only_raw_delta_is_pure_add() { var prev = RawTag("Plant/Modbus1/PLC-A/Speed"); var cur = prev with { ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" } }; var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") }, ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) }, }; AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); } }