90d7f6dfca
DriverHostActor no longer acks Applied the moment the revision advances. It asks OpcUaPublishActor for the outcome (RebuildAddressSpace.ReportOutcome) and acks Applied+Degraded(reason) when the apply was degraded; a missing outcome times out (60 s, inside the coordinator's 2 min deadline) to a degraded ack, never a clean one. Re-dispatching a revision whose apply was degraded or is still unconfirmed re-applies instead of taking the clean idempotent short-circuit - that re-apply is also what repairs the degraded nodes. Tests with a bare publish probe use a clean-outcome auto-pilot probe. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
211 lines
12 KiB
C#
211 lines
12 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
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.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
/// <summary>
|
|
/// Shared v3 deployment fixture for the <see cref="DriverHostActor"/> value-routing suites: seeds a
|
|
/// Sealed artifact carrying the raw-tag chain (+ optional UNS references) and spawns a host that has
|
|
/// actually applied it, so <c>_nodeIdByDriverRef</c> is populated before the test publishes anything.
|
|
/// <para>
|
|
/// Kept here rather than in either test class because the per-value and batched fan-out suites
|
|
/// assert different deliveries over the SAME arrangement — duplicating the seeder let the two
|
|
/// copies drift. Deliberately narrow: the assembly's other artifact fixtures seed their own
|
|
/// shapes and are not folded in.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class V3ArtifactSeeder
|
|
{
|
|
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
|
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
|
|
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
|
|
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
|
|
/// numerically (AccessLevel: ReadWrite = 1).</summary>
|
|
/// <param name="db">The in-memory config-db factory to seed.</param>
|
|
/// <param name="rev">The revision hash the deployment is sealed under.</param>
|
|
/// <param name="rawTags">The raw tags to materialise, one per (driver, device, tag).</param>
|
|
/// <param name="unsRefs">UNS references projecting a raw tag (by <c>BackingTag</c>) into an equipment.</param>
|
|
/// <param name="enabled">Whether the seeded <c>DriverInstances</c> are Enabled — i.e. whether applying this
|
|
/// artifact actually SPAWNS driver children. Defaults to <c>false</c>, the shape every pre-existing caller
|
|
/// relies on: the value-routing suites inject values straight into the host and want no children at all.
|
|
/// The restart/re-subscribe suite (03/S16) passes <c>true</c>, because its whole subject is what a spawned
|
|
/// child is handed.</param>
|
|
/// <param name="tagConfigByTag">Optional per-tag <c>TagConfig</c> JSON, keyed by tag name; tags not named
|
|
/// here keep the default <c>"{}"</c>. The only way to seed an <b>alarm-bearing</b> raw tag (the alarm intent
|
|
/// is parsed out of <c>TagConfig.alarm</c> by <c>TagConfigIntent</c>), which the alarm-ref half of the
|
|
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
|
|
/// in the alarm one instead, so the two travel through different child code paths.</param>
|
|
/// <param name="taglessDrivers">Extra <c>DriverInstance</c> ids carrying NO devices and NO tags. The
|
|
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
|
|
/// maps — the <c>_children.Keys</c> arm of <c>ApplyDesiredSubscriptions</c>'s union, which must still
|
|
/// hand that child an explicit EMPTY set rather than skipping it.</param>
|
|
/// <returns>The seeded deployment's id.</returns>
|
|
public static DeploymentId SeedV3Deployment(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
|
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
|
|
bool enabled = false,
|
|
IReadOnlyDictionary<string, string>? tagConfigByTag = null,
|
|
string[]? taglessDrivers = null)
|
|
{
|
|
var drivers = rawTags
|
|
.Select(t => (t.Driver, t.DriverName))
|
|
.Distinct()
|
|
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
|
|
.Concat((taglessDrivers ?? Array.Empty<string>())
|
|
.Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
|
|
.ToArray();
|
|
var devices = rawTags
|
|
.Select(t => (t.Driver, t.Device))
|
|
.Distinct()
|
|
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
|
|
.ToArray();
|
|
var tags = rawTags.Select(t => new
|
|
{
|
|
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
|
|
DeviceId = $"{t.Driver}:{t.Device}",
|
|
TagGroupId = (string?)null,
|
|
Name = t.Tag,
|
|
DataType = t.DataType,
|
|
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
|
TagConfig = tagConfigByTag is not null && tagConfigByTag.TryGetValue(t.Tag, out var tc) ? tc : "{}",
|
|
}).ToArray();
|
|
|
|
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
|
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
|
|
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
|
|
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
|
|
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
|
|
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
|
|
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
|
|
var unsTagReferences = unsRefs.Select((r, i) => new
|
|
{
|
|
UnsTagReferenceId = $"ref-{i}",
|
|
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
|
|
TagId = r.BackingTag,
|
|
DisplayNameOverride = r.Effective,
|
|
}).ToArray();
|
|
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = drivers,
|
|
Devices = devices,
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = tags,
|
|
UnsAreas = areas,
|
|
UnsLines = lines,
|
|
Equipment = equipment,
|
|
UnsTagReferences = unsTagReferences,
|
|
});
|
|
|
|
var id = DeploymentId.NewId();
|
|
using var ctx = db.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = rev.Value,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
|
|
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
|
|
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
|
/// <param name="kit">The calling TestKit, supplying the ActorSystem + probes.</param>
|
|
/// <param name="db">The factory whose deployment was seeded by <see cref="SeedV3Deployment"/>.</param>
|
|
/// <param name="localNode">The host's local node id.</param>
|
|
/// <param name="deploymentId">The deployment to dispatch.</param>
|
|
/// <param name="rev">The revision hash the deployment was sealed under.</param>
|
|
/// <param name="driverFactory">Optional driver factory forwarded to <c>DriverHostActor.Props</c>. Null (the
|
|
/// default, preserving every existing call site) leaves the host on its own default, which materialises no
|
|
/// real drivers. Supply one — with <c>enabled: true</c> on the seed — to actually spawn driver children.</param>
|
|
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
|
|
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
|
|
/// deferral without waiting the production window.</param>
|
|
/// <param name="commsRecoveryAuditDelay">Optional 03/S15 audit delay — how long after a reconnect the
|
|
/// host checks for refs still latched <c>BadNoCommunication</c>. Null keeps the production default,
|
|
/// which is far longer than any test's lifetime.</param>
|
|
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
|
|
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
|
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
|
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
|
IDriverFactory? driverFactory = null,
|
|
TimeSpan? reprovisionBackoff = null,
|
|
TimeSpan? commsRecoveryAuditDelay = null)
|
|
{
|
|
var publish = kit.CreateCleanOutcomePublishProbe();
|
|
var (actor, mux, coordinator) =
|
|
Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay);
|
|
|
|
Dispatch(actor, coordinator, deploymentId, rev);
|
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
|
|
|
return (actor, publish, mux);
|
|
}
|
|
|
|
/// <summary>The same arrangement with NO OPC UA publish actor wired (the ctor default) — the dev/Mac
|
|
/// shape, where the mux hop must still happen. Returns only the mux probe, since there is no sink.</summary>
|
|
/// <param name="kit">The calling TestKit, supplying the ActorSystem + probes.</param>
|
|
/// <param name="db">The factory whose deployment was seeded by <see cref="SeedV3Deployment"/>.</param>
|
|
/// <param name="localNode">The host's local node id.</param>
|
|
/// <param name="deploymentId">The deployment to dispatch.</param>
|
|
/// <param name="rev">The revision hash the deployment was sealed under.</param>
|
|
/// <returns>The host actor plus the mux probe wired into it.</returns>
|
|
public static (IActorRef Actor, TestProbe Mux) SpawnHostAndApplyWithoutPublishActor(
|
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
|
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
|
|
{
|
|
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null, reprovisionBackoff: null);
|
|
Dispatch(actor, coordinator, deploymentId, rev);
|
|
return (actor, mux);
|
|
}
|
|
|
|
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
|
|
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
|
|
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff,
|
|
TimeSpan? commsRecoveryAuditDelay = null)
|
|
{
|
|
var coordinator = kit.CreateTestProbe();
|
|
var mux = kit.CreateTestProbe();
|
|
var vtHost = kit.CreateTestProbe();
|
|
|
|
var actor = kit.Sys.ActorOf(DriverHostActor.Props(
|
|
db, localNode, coordinator.Ref,
|
|
driverFactory: driverFactory,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
dependencyMux: mux.Ref,
|
|
opcUaPublishActor: opcUaPublishActor,
|
|
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
|
virtualTagHostOverride: vtHost.Ref,
|
|
reprovisionBackoff: reprovisionBackoff,
|
|
commsRecoveryAuditDelay: commsRecoveryAuditDelay));
|
|
|
|
return (actor, mux, coordinator);
|
|
}
|
|
|
|
private static void Dispatch(IActorRef actor, TestProbe coordinator, DeploymentId deploymentId, RevisionHash rev)
|
|
{
|
|
actor.Tell(new DispatchDeployment(deploymentId, rev, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(RuntimeActorTestBase.PresenceBudget)
|
|
.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
}
|
|
}
|