Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Phase7Composer now carries UnsAreaProjection + UnsLineProjection lists so the applier can materialise the full UNS topology in the OPC UA address space. New IOpcUaAddressSpaceSink.EnsureFolder(folderNodeId, parentNodeId, displayName) seam (no-op default, recorded in tests, forwarded by DeferredAddressSpaceSink, implemented by SdkAddressSpaceSink). The SDK- side OtOpcUaNodeManager gains an EnsureFolder API that creates FolderState nodes with proper parent linkage; RebuildAddressSpace now clears folders too so re-applies don't accumulate stale topology. Phase7Applier.MaterialiseHierarchy walks composition.UnsAreas → composition.UnsLines → composition.EquipmentNodes, calling EnsureFolder with the correct parent at each level. Idempotent — calling twice with the same composition is a no-op. OpcUaPublishActor.HandleRebuild invokes it after Phase7Applier.Apply so OPC UA clients browsing the server now see Area/Line/Equipment as proper folders rather than flat tag ids. DeploymentArtifact.ParseComposition reads UnsAreas + UnsLines from the JSON snapshot the ControlPlane emits, populating the new fields when present. Phase7Composer.Compose now accepts UnsAreas + UnsLines; a 3-arg overload preserves the old signature for legacy callers + existing tests. The Phase7CompositionResult convenience ctor likewise keeps the planner tests working without UNS data. 3 new hierarchy tests (pure unit + boot-verify against a real OtOpcUaSdkServer); OpcUaServer suite is 48/48 green (was 45, +3), Runtime 74/74 unchanged. Closes #85.
147 lines
5.4 KiB
C#
147 lines
5.4 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
|
|
|
public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|
{
|
|
[Fact]
|
|
public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
|
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
sink: sink,
|
|
dbFactory: db,
|
|
applier: applier));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
// Add path: Equipment + Driver + Alarm — but only Equipment/Alarm topology triggers
|
|
// RebuildAddressSpace. With 2 new equipment we expect one Rebuild call.
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}, duration: TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rebuild_with_no_artifact_is_idempotent_no_op()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
|
|
// No deployment seeded — LoadLatestArtifact returns empty blob.
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
sink: sink,
|
|
dbFactory: db,
|
|
applier: applier));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
Thread.Sleep(200);
|
|
|
|
sink.RebuildCalls.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Second_rebuild_with_same_artifact_is_empty_plan_no_op()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var sink = new RecordingSink();
|
|
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
|
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
|
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
sink: sink, dbFactory: db, applier: applier));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
Thread.Sleep(200);
|
|
// Same composition ⇒ plan IsEmpty ⇒ applier not called again.
|
|
sink.RebuildCalls.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Rebuild_without_dbFactory_falls_back_to_raw_sink_rebuild()
|
|
{
|
|
// Pre-#109 behavior: no dbFactory wired ⇒ RebuildAddressSpace calls _sink.RebuildAddressSpace
|
|
// directly. The dev/Mac path before the full integration is bound.
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
private static void SeedDeployment(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
|
string[] equipmentIds,
|
|
string[] driverIds)
|
|
{
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Equipment = equipmentIds.Select(id => new
|
|
{
|
|
EquipmentId = id,
|
|
MachineCode = id.ToUpperInvariant(),
|
|
UnsLineId = "line-1",
|
|
Name = id,
|
|
}).ToArray(),
|
|
DriverInstances = driverIds.Select(id => new
|
|
{
|
|
DriverInstanceId = id,
|
|
DriverType = "Modbus",
|
|
Enabled = true,
|
|
DriverConfig = "{}",
|
|
}).ToArray(),
|
|
ScriptedAlarms = Array.Empty<object>(),
|
|
});
|
|
|
|
using var ctx = dbFactory.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = Guid.NewGuid(),
|
|
RevisionHash = new string('a', 64),
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
public ConcurrentQueue<string> Calls { get; } = new();
|
|
public int RebuildCalls;
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
|
=> Calls.Enqueue($"WV:{nodeId}");
|
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
|
|
=> Calls.Enqueue($"WA:{alarmNodeId}");
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
|
=> Calls.Enqueue($"EF:{folderNodeId}");
|
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
|
}
|
|
}
|