using System.Text; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts; using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts; /// /// Review D-b r1, I1: /hosts keys eviction on authored ∪ DEPLOYED drivers, because a running driver /// whose health is unchanged never re-publishes (fingerprint dedup) — staleness is not a liveness proof. /// [Trait("Category", "Unit")] public sealed class DeployedDriverSetTests { private static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); // Test artifacts are a plain comma list; the real parser is DeploymentArtifact.ParseDriverInstances. private static IEnumerable Parse(byte[] blob) => Encoding.UTF8.GetString(blob).Split(',', StringSplitOptions.RemoveEmptyEntries); private static byte[] Artifact(params string[] driverIds) => Encoding.UTF8.GetBytes(string.Join(",", driverIds)); private sealed class Factory(string name) : IDbContextFactory { public int Created; public OtOpcUaConfigDbContext CreateDbContext() { Created++; return new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); } public Task CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext()); } private static Deployment Dep(Guid id, DeploymentStatus status, DateTime created, byte[] blob) => new() { DeploymentId = id, RevisionHash = id.ToString("N"), CreatedBy = "t", Status = status, CreatedAtUtc = created, ArtifactBlob = blob }; private static NodeDeploymentState Node(string node, Guid dep, NodeDeploymentStatus status, DateTime started) => new() { NodeId = node, DeploymentId = dep, Status = status, StartedAtUtc = started }; [Fact] public async Task Quiet_running_driver_deleted_in_raw_survives_the_sweep() { // drv-quiet is running (in the deployed artifact), deleted in /raw (not authored), and published // once 5 minutes ago — a Faulted driver with a steady error never re-publishes. var factory = new Factory(Guid.NewGuid().ToString("N")); var d1 = Guid.NewGuid(); await using (var db = factory.CreateDbContext()) { db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now.AddHours(-1), Artifact("drv-quiet", "drv-kept"))); db.NodeDeploymentStates.Add(Node("n1", d1, NodeDeploymentStatus.Applied, Now.AddHours(-1))); await db.SaveChangesAsync(); } var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync(); deployed.ShouldNotBeNull(); var store = new InMemoryDriverStatusSnapshotStore(); store.Upsert(new DriverHealthChanged("MAIN", "drv-quiet", "Faulted", null, "refused", 3, Now.AddMinutes(-5))); store.Upsert(new DriverHealthChanged("MAIN", "drv-gone", "Faulted", null, "refused", 3, Now.AddMinutes(-5))); var configured = new HashSet(["drv-kept"], StringComparer.OrdinalIgnoreCase); // authored configured.UnionWith(deployed!); var removed = store.EvictUnconfiguredStale(configured, Now.AddSeconds(-60)); store.TryGet("drv-quiet", out _).ShouldBeTrue(); // Positive control: an undeployed, unconfigured, stale driver is still evicted. removed.ShouldBe(1); store.TryGet("drv-gone", out _).ShouldBeFalse(); } [Fact] public async Task Union_covers_each_nodes_latest_and_latest_applied_and_the_latest_sealed() { var factory = new Factory(Guid.NewGuid().ToString("N")); Guid old = Guid.NewGuid(), failed = Guid.NewGuid(), sealedOther = Guid.NewGuid(), stale = Guid.NewGuid(); await using (var db = factory.CreateDbContext()) { db.Deployments.Add(Dep(stale, DeploymentStatus.Sealed, Now.AddDays(-9), Artifact("drv-ancient"))); db.Deployments.Add(Dep(old, DeploymentStatus.Sealed, Now.AddHours(-3), Artifact("drv-old"))); db.Deployments.Add(Dep(failed, DeploymentStatus.PartiallyFailed, Now.AddHours(-1), Artifact("drv-new"))); db.Deployments.Add(Dep(sealedOther, DeploymentStatus.Sealed, Now.AddHours(-2), Artifact("drv-sealed"))); db.NodeDeploymentStates.Add(Node("n1", stale, NodeDeploymentStatus.Applied, Now.AddDays(-9))); db.NodeDeploymentStates.Add(Node("n1", old, NodeDeploymentStatus.Applied, Now.AddHours(-3))); db.NodeDeploymentStates.Add(Node("n1", failed, NodeDeploymentStatus.Failed, Now.AddHours(-1))); await db.SaveChangesAsync(); } var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync(); // n1's latest (failed apply, may be partly running), its latest Applied (what it ran before), and the // latest Sealed deployment — but not a superseded one nobody runs. deployed!.ShouldBe(["drv-new", "drv-old", "drv-sealed"], ignoreOrder: true); deployed!.ShouldNotContain("drv-ancient"); } [Fact] public async Task Artifacts_are_parsed_once_per_deployment_and_pruned() { var factory = new Factory(Guid.NewGuid().ToString("N")); var d1 = Guid.NewGuid(); await using (var db = factory.CreateDbContext()) { db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Artifact("drv-a"))); await db.SaveChangesAsync(); } var parses = 0; var set = new DeployedDriverSet(factory, parseDriverIds: b => { parses++; return Parse(b); }); await set.LoadAsync(); await set.LoadAsync(); parses.ShouldBe(1); set.CachedDeployments.ShouldBe(1); var d2 = Guid.NewGuid(); await using (var db = factory.CreateDbContext()) { db.Deployments.Add(Dep(d2, DeploymentStatus.Sealed, Now.AddMinutes(1), Artifact("drv-b"))); await db.SaveChangesAsync(); } (await set.LoadAsync())!.ShouldBe(["drv-b"]); set.CachedDeployments.ShouldBe(1); // d1 is no longer live, so it is pruned parses.ShouldBe(2); } [Fact] public async Task An_unreadable_artifact_means_unknown_so_nothing_is_evicted() { var factory = new Factory(Guid.NewGuid().ToString("N")); var d1 = Guid.NewGuid(); await using (var db = factory.CreateDbContext()) { db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Array.Empty())); // retention-emptied await db.SaveChangesAsync(); } (await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync()).ShouldBeNull(); } [Fact] public void Hosts_skips_eviction_when_the_deployed_set_is_unknown() { var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor"); src.ShouldContain("var deployed = await Deployed.LoadAsync(_cts.Token);"); src.ShouldContain("configured.UnionWith(deployed);"); src.IndexOf("if (deployed is not null)", StringComparison.Ordinal) .ShouldBeLessThan(src.IndexOf("DriverStore.EvictUnconfiguredStale(", StringComparison.Ordinal)); src.ShouldNotContain("≈ 12 health publishes"); } }