feat(opcua): pure Phase7Composer + purity tests (side-effects tracked as F14)
This commit is contained in:
@@ -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" />
|
||||
|
||||
49
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
Normal file
49
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user