feat(opcua): pure Phase7Composer + purity tests (side-effects tracked as F14)

This commit is contained in:
Joseph Doherty
2026-05-26 05:14:45 -04:00
parent 2877a883cd
commit b7c117ab31
4 changed files with 183 additions and 0 deletions

View File

@@ -64,6 +64,7 @@
</Folder>
<Folder Name="/tests/Server/">
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj" />
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj" />
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj" />

View File

@@ -0,0 +1,49 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.</summary>
public sealed record Phase7CompositionResult(
IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> 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);
/// <summary>
/// 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 <c>Server.Phase7.Phase7Composer</c> (which mutates a server-side
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) 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.
/// </summary>
public static class Phase7Composer
{
public static Phase7CompositionResult Compose(
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> 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);
}
}

View File

@@ -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<Equipment>(),
driverInstances: Array.Empty<DriverInstance>(),
scriptedAlarms: Array.Empty<ScriptedAlarm>());
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<T> 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<DriverInstance>(), Array.Empty<ScriptedAlarm>());
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",
};
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>