Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs
T
Joseph Doherty 23b42b424d fix(code-review): resolve OpcUaServer-001 — UNS Area/Line rename refreshes folder DisplayName
A rename-only deploy produced an IsEmpty plan that short-circuited before MaterialiseHierarchy,
leaving the OPC UA folder DisplayName stale. AddressSpacePlanner now diffs UnsAreas/UnsLines by
stable id into a RenamedFolders set (counted in IsEmpty); the applier refreshes the folder in
place via a new UpdateFolderDisplayName on ISurgicalAddressSpaceSink (forwarded through
DeferredAddressSpaceSink so it is NOT inert on driver hosts; falls back to rebuild when the sink
is non-surgical). DeploymentArtifact byte-parity untouched (rename rides the existing Name
round-trip). No EF migration, no serialized wire/proto contract change. +13 OpcUaServer tests, Runtime rebuild test.
2026-06-20 23:10:24 -04:00

503 lines
25 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class AddressSpacePlannerTests
{
/// <summary>Verifies that empty inputs produce an empty plan.</summary>
[Fact]
public void Empty_inputs_produce_empty_plan()
{
var prev = new AddressSpaceComposition(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = prev;
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies that identical compositions produce an empty plan.</summary>
[Fact]
public void Identical_compositions_produce_empty_plan()
{
var eq = new EquipmentNode("eq-1", "Eq 1", "line-1");
var prev = new AddressSpaceComposition(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(new[] { eq }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
/// IsEmpty gate before materialising the new equipment variables.</summary>
[Fact]
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
[Fact]
public void Equipment_virtual_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a disappeared VirtualTag routes to RemovedEquipmentVirtualTags.</summary>
[Fact]
public void Disappeared_virtual_tag_goes_to_RemovedEquipmentVirtualTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.RemovedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag with the same id but a different Expression routes to
/// ChangedEquipmentVirtualTags (the diff identity is VirtualTagId; any field difference,
/// including the evaluated Expression, moves it from stable to changed).</summary>
[Fact]
public void Same_id_with_different_expression_routes_to_ChangedEquipmentVirtualTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a - b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentVirtualTags.Single().Previous.Expression.ShouldBe("a + b");
plan.ChangedEquipmentVirtualTags.Single().Current.Expression.ShouldBe("a - b");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>H5a — a VirtualTag with the same id but a toggled <c>Historize</c> flag (and otherwise
/// identical fields) must route to ChangedEquipmentVirtualTags. This pins that <c>Historize</c> is
/// part of <see cref="EquipmentVirtualTagPlan.Equals"/> so a Historize-only deploy is not a silent
/// no-op at the diff/IsEmpty gate.</summary>
[Fact]
public void Same_id_with_toggled_historize_routes_to_ChangedEquipmentVirtualTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }, Historize: false),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }, Historize: true),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentVirtualTags.Single().Previous.Historize.ShouldBeFalse();
plan.ChangedEquipmentVirtualTags.Single().Current.Historize.ShouldBeTrue();
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>An equipment Tag with the same id but a toggled <c>IsArray</c> flag (and otherwise
/// identical fields) must route to ChangedEquipmentTags. This pins that <c>IsArray</c> is part of
/// <see cref="EquipmentTagPlan.Equals"/> (record value-equality) so an array-flag-only deploy is not
/// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag.</summary>
[Fact]
public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse();
plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue();
plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
/// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits).</summary>
[Fact]
public void Identical_virtualtag_snapshots_diff_to_empty_plan()
{
// Two separate list instances with identical contents — proves structural (not reference) equality.
var refsA = new[] { "EQ1.Speed", "EQ1.Torque" };
var refsB = new[] { "EQ1.Speed", "EQ1.Torque" };
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA),
},
};
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB),
},
};
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
[Fact]
public void New_equipment_goes_to_AddedEquipment()
{
var prev = new AddressSpaceComposition(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "A", "line-1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-1");
plan.RemovedEquipment.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that disappeared equipment goes to the RemovedEquipment list.</summary>
[Fact]
public void Disappeared_equipment_goes_to_RemovedEquipment()
{
var prev = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "A", "line-1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-1");
plan.AddedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that equipment with same id but different display name routes to ChangedEquipment.</summary>
[Fact]
public void Same_id_with_different_display_name_routes_to_ChangedEquipment()
{
var prev = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "Old", "line-1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-1", "New", "line-1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.ChangedEquipment.Single().Previous.DisplayName.ShouldBe("Old");
plan.ChangedEquipment.Single().Current.DisplayName.ShouldBe("New");
plan.AddedEquipment.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that driver config changes route to ChangedDrivers.</summary>
[Fact]
public void Driver_config_change_routes_to_ChangedDrivers()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(),
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"old\"}") },
Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(),
new[] { new DriverInstancePlan("drv-1", "Modbus", "{\"host\":\"new\"}") },
Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.ChangedDrivers.Single().Current.ConfigJson.ShouldContain("new");
}
/// <summary>Verifies that alarm message template changes route to ChangedAlarms.</summary>
[Fact]
public void Alarm_message_template_change_routes_to_ChangedAlarms()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "old") });
var next = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
new[] { new ScriptedAlarmPlan("a-1", "eq-1", "script-1", "new") });
var plan = AddressSpacePlanner.Compute(prev, next);
plan.ChangedAlarms.Single().Current.MessageTemplate.ShouldBe("new");
}
/// <summary>Verifies that added and removed lists are sorted by id for deterministic ordering.</summary>
[Fact]
public void Added_and_removed_lists_are_sorted_by_id_for_deterministic_ordering()
{
var prev = new AddressSpaceComposition(
new[] { new EquipmentNode("z", "Z", "line-1"), new EquipmentNode("a", "A", "line-1") },
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" });
}
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area's
/// <c>DisplayName</c> differs; same id, no equipment/driver/alarm/tag/vtag delta) must yield a
/// NON-empty plan with the rename in <see cref="AddressSpacePlan.RenamedFolders"/>, so
/// <c>OpcUaPublishActor.HandleRebuild</c> no longer short-circuits at the IsEmpty gate before the
/// folder's DisplayName is refreshed.</summary>
[Fact]
public void Area_rename_only_yields_non_empty_plan_with_renamed_folder()
{
var prev = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
var rename = plan.RenamedFolders.ShouldHaveSingleItem();
rename.FolderNodeId.ShouldBe("area-1"); // folder NodeId == the area id (MaterialiseHierarchy scheme)
rename.NewDisplayName.ShouldBe("Plant South");
}
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Line rename yields a NON-empty plan
/// with the rename in <see cref="AddressSpacePlan.RenamedFolders"/> (folder NodeId == the line id).</summary>
[Fact]
public void Line_rename_only_yields_non_empty_plan_with_renamed_folder()
{
var prev = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
var rename = plan.RenamedFolders.ShouldHaveSingleItem();
rename.FolderNodeId.ShouldBe("line-1");
rename.NewDisplayName.ShouldBe("Cell B");
}
/// <summary>OpcUaServer-001 — no false positives: two compositions with identical Area + Line topology
/// (same ids, same DisplayNames) produce NO rename and an empty plan. Pins that the rename diff fires
/// ONLY on an actual DisplayName change, not on every redeploy.</summary>
[Fact]
public void Identical_area_line_topology_yields_empty_plan_no_renames()
{
var areas = new[] { new UnsAreaProjection("area-1", "Plant North") };
var lines = new[] { new UnsLineProjection("line-1", "area-1", "Cell A") };
var prev = new AddressSpaceComposition(areas, lines, Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
// Fresh projection instances, identical contents — proves value (not reference) comparison.
var next = new AddressSpaceComposition(
new[] { new UnsAreaProjection("area-1", "Plant North") },
new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
plan.RenamedFolders.ShouldBeEmpty();
}
/// <summary>OpcUaServer-001 — a brand-new Area (added, not renamed) is NOT a rename: an area present
/// only in <c>next</c> is materialised by the hierarchy pass, not refreshed in place. The rename diff
/// must only flag folders present in BOTH snapshots whose DisplayName changed.</summary>
[Fact]
public void Added_area_is_not_a_rename()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RenamedFolders.ShouldBeEmpty();
}
/// <summary>OpcUaServer-001 — when BOTH an Area and a Line are renamed in one deploy, both renames
/// appear in <see cref="AddressSpacePlan.RenamedFolders"/>, ordered deterministically by folder id
/// (areas + lines concatenated, each sorted by id).</summary>
[Fact]
public void Area_and_line_renames_both_captured_and_ordered_by_id()
{
var prev = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RenamedFolders.Count.ShouldBe(2);
// "area-1" sorts before "line-1" (areas first, then lines, each id-sorted) — deterministic order.
plan.RenamedFolders.Select(r => r.FolderNodeId).ShouldBe(new[] { "area-1", "line-1" });
plan.RenamedFolders.Single(r => r.FolderNodeId == "area-1").NewDisplayName.ShouldBe("South");
plan.RenamedFolders.Single(r => r.FolderNodeId == "line-1").NewDisplayName.ShouldBe("Cell B");
}
/// <summary>Verifies that mixed changes across all three classes are captured in one pass.</summary>
[Fact]
public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass()
{
var prev = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-drop", "Drop", "line-1") },
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":1}") },
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1") });
var next = new AddressSpaceComposition(
new[] { new EquipmentNode("eq-keep", "Keep", "line-1"), new EquipmentNode("eq-new", "New", "line-1") },
new[] { new DriverInstancePlan("drv-keep", "Modbus", "{}"), new DriverInstancePlan("drv-change", "Modbus", "{\"v\":2}") },
new[] { new ScriptedAlarmPlan("a-keep", "eq-keep", "s1", "t1"), new ScriptedAlarmPlan("a-new", "eq-new", "s2", "t2") });
var plan = AddressSpacePlanner.Compute(prev, next);
plan.AddedEquipment.Single().EquipmentId.ShouldBe("eq-new");
plan.RemovedEquipment.Single().EquipmentId.ShouldBe("eq-drop");
plan.ChangedEquipment.ShouldBeEmpty();
plan.ChangedDrivers.Single().Current.DriverInstanceId.ShouldBe("drv-change");
plan.AddedAlarms.Single().ScriptedAlarmId.ShouldBe("a-new");
}
}