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; /// /// Shared v3 deployment fixture for the 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 _nodeIdByDriverRef is populated before the test publishes anything. /// /// 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. /// /// public static class V3ArtifactSeeder { /// 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 Plant/{DriverName}/{Device}/{Tag}; each UNS /// reference's NodeId is {Area}/{Line}/{Equip}/{Effective ?? backing tag Name}. Enums serialize /// numerically (AccessLevel: ReadWrite = 1). /// The in-memory config-db factory to seed. /// The revision hash the deployment is sealed under. /// The raw tags to materialise, one per (driver, device, tag). /// UNS references projecting a raw tag (by BackingTag) into an equipment. /// Whether the seeded DriverInstances are Enabled — i.e. whether applying this /// artifact actually SPAWNS driver children. Defaults to false, 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 true, because its whole subject is what a spawned /// child is handed. /// Optional per-tag TagConfig JSON, keyed by tag name; tags not named /// here keep the default "{}". The only way to seed an alarm-bearing raw tag (the alarm intent /// is parsed out of TagConfig.alarm by TagConfigIntent), 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. /// Extra DriverInstance 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 _children.Keys arm of ApplyDesiredSubscriptions's union, which must still /// hand that child an explicit EMPTY set rather than skipping it. /// The seeded deployment's id. public static DeploymentId SeedV3Deployment( IDbContextFactory 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? 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()) .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(), 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; } /// 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. /// The calling TestKit, supplying the ActorSystem + probes. /// The factory whose deployment was seeded by . /// The host's local node id. /// The deployment to dispatch. /// The revision hash the deployment was sealed under. /// Optional driver factory forwarded to DriverHostActor.Props. Null (the /// default, preserving every existing call site) leaves the host on its own default, which materialises no /// real drivers. Supply one — with enabled: true on the seed — to actually spawn driver children. /// Optional per-driver supervision-reprovision rate limit forwarded to /// DriverHostActor.Props; null keeps the production default. Exists so a test can observe the /// deferral without waiting the production window. /// Optional 03/S15 audit delay — how long after a reconnect the /// host checks for refs still latched BadNoCommunication. Null keeps the production default, /// which is far longer than any test's lifetime. /// The host actor plus the publish and mux probes wired into it. public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply( TestKitBase kit, IDbContextFactory db, NodeId localNode, DeploymentId deploymentId, RevisionHash rev, IDriverFactory? driverFactory = null, TimeSpan? reprovisionBackoff = null, TimeSpan? commsRecoveryAuditDelay = null) { var publish = kit.CreateTestProbe(); var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff, commsRecoveryAuditDelay); Dispatch(actor, coordinator, deploymentId, rev); publish.ExpectMsg(RuntimeActorTestBase.PresenceBudget); return (actor, publish, mux); } /// 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. /// The calling TestKit, supplying the ActorSystem + probes. /// The factory whose deployment was seeded by . /// The host's local node id. /// The deployment to dispatch. /// The revision hash the deployment was sealed under. /// The host actor plus the mux probe wired into it. public static (IActorRef Actor, TestProbe Mux) SpawnHostAndApplyWithoutPublishActor( TestKitBase kit, IDbContextFactory 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 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 { "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(RuntimeActorTestBase.PresenceBudget) .Outcome.ShouldBe(ApplyAckOutcome.Applied); } }