From b7c117ab31ec79aab1fa7aebf473f03157465295 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 05:14:45 -0400 Subject: [PATCH] feat(opcua): pure Phase7Composer + purity tests (side-effects tracked as F14) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../Phase7Composer.cs | 49 +++++++++ .../Phase7ComposerPurityTests.cs | 102 ++++++++++++++++++ ...ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj | 31 ++++++ 4 files changed, 183 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index dc88700..54a6676 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -64,6 +64,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs new file mode 100644 index 0000000..4a3301c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -0,0 +1,49 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// Outcome of — pure value tuple, no side effects. +public sealed record Phase7CompositionResult( + IReadOnlyList EquipmentNodes, + IReadOnlyList DriverInstancePlans, + IReadOnlyList ScriptedAlarmPlans); + +public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId); +public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); +public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); + +/// +/// Pure composer that flattens the live-edit DB tables into the address-space build plan a +/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role +/// startup (Task 53) consumes the result and hands it to the node-manager factory. +/// +/// Full migration of the legacy Server.Phase7.Phase7Composer (which mutates a server-side +/// node cache, emits trace logs, and calls into EquipmentNodeWalker) is tracked as +/// follow-up F14. This pure version handles the projection step; the side-effecting wiring +/// stays in the legacy code until F14 lands. +/// +public static class Phase7Composer +{ + public static Phase7CompositionResult Compose( + IReadOnlyList equipment, + IReadOnlyList driverInstances, + IReadOnlyList scriptedAlarms) + { + var nodes = equipment + .OrderBy(e => e.EquipmentId, StringComparer.Ordinal) + .Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId)) + .ToList(); + + var plans = driverInstances + .OrderBy(d => d.DriverInstanceId, StringComparer.Ordinal) + .Select(d => new DriverInstancePlan(d.DriverInstanceId, d.DriverType, d.DriverConfig)) + .ToList(); + + var alarms = scriptedAlarms + .OrderBy(a => a.ScriptedAlarmId, StringComparer.Ordinal) + .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) + .ToList(); + + return new Phase7CompositionResult(nodes, plans, alarms); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs new file mode 100644 index 0000000..fb8f022 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerPurityTests.cs @@ -0,0 +1,102 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +public sealed class Phase7ComposerPurityTests +{ + [Fact] + public void Empty_inputs_produce_empty_result() + { + var result = Phase7Composer.Compose( + equipment: Array.Empty(), + driverInstances: Array.Empty(), + scriptedAlarms: Array.Empty()); + + result.EquipmentNodes.ShouldBeEmpty(); + result.DriverInstancePlans.ShouldBeEmpty(); + result.ScriptedAlarmPlans.ShouldBeEmpty(); + } + + [Fact] + public void Same_inputs_in_different_order_produce_structurally_equal_results() + { + var e1 = NewEquipment("eq-1"); + var e2 = NewEquipment("eq-2"); + var d1 = NewDriver("drv-1"); + var d2 = NewDriver("drv-2"); + var a1 = NewAlarm("a-1", "eq-1"); + var a2 = NewAlarm("a-2", "eq-2"); + + var r1 = Phase7Composer.Compose( + equipment: new[] { e1, e2 }, + driverInstances: new[] { d1, d2 }, + scriptedAlarms: new[] { a1, a2 }); + + var r2 = Phase7Composer.Compose( + equipment: new[] { e2, e1 }, + driverInstances: new[] { d2, d1 }, + scriptedAlarms: new[] { a2, a1 }); + + r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes); + r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans); + r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans); + } + + [Fact] + public void Compose_is_pure_repeated_call_returns_element_identical_output() + { + var equipment = new[] { NewEquipment("eq-a"), NewEquipment("eq-b") }; + var drivers = new[] { NewDriver("drv-x") }; + var alarms = new[] { NewAlarm("alarm-1", "eq-a") }; + + var r1 = Phase7Composer.Compose(equipment, drivers, alarms); + var r2 = Phase7Composer.Compose(equipment, drivers, alarms); + + // Record equality won't help here — IReadOnlyList uses reference equality. Compare + // element-wise to verify the pure-function contract. + r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes); + r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans); + r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans); + } + + [Fact] + public void Output_is_sorted_by_natural_key() + { + var equipment = new[] { NewEquipment("z"), NewEquipment("a"), NewEquipment("m") }; + var result = Phase7Composer.Compose(equipment, Array.Empty(), Array.Empty()); + + result.EquipmentNodes.Select(e => e.EquipmentId) + .ShouldBe(new[] { "a", "m", "z" }); + } + + private static Equipment NewEquipment(string id) => new() + { + EquipmentId = id, + DriverInstanceId = "drv-1", + UnsLineId = "line-1", + Name = id, + MachineCode = id.ToUpperInvariant(), + }; + + private static DriverInstance NewDriver(string id) => new() + { + DriverInstanceId = id, + ClusterId = "cluster-1", + NamespaceId = "ns-1", + Name = id, + DriverType = "Stub", + DriverConfig = "{\"k\":\"v\"}", + }; + + private static ScriptedAlarm NewAlarm(string id, string equipmentId) => new() + { + ScriptedAlarmId = id, + EquipmentId = equipmentId, + Name = id, + AlarmType = "AlarmCondition", + MessageTemplate = "{TagPath} alarm", + PredicateScriptId = "script-1", + }; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj new file mode 100644 index 0000000..7177975 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj @@ -0,0 +1,31 @@ + + + + false + true + ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + +