Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/DeployedDriverSetTests.cs
T
Joseph Doherty b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
test: migrate AdminUI/ControlPlane/Runtime/Host tests to xunit.v3 (#756)
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via
VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask,
SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass
TestContext.Current.CancellationToken. Test counts unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 08:57:33 -05:00

158 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Review D-b r1, I1: <c>/hosts</c> 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.
/// </summary>
[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<string> 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<OtOpcUaConfigDbContext>
{
public int Created;
public OtOpcUaConfigDbContext CreateDbContext()
{
Created++;
return new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
}
public Task<OtOpcUaConfigDbContext> 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(TestContext.Current.CancellationToken);
}
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync(TestContext.Current.CancellationToken);
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<string>(["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(TestContext.Current.CancellationToken);
}
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync(TestContext.Current.CancellationToken);
// 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(TestContext.Current.CancellationToken);
}
var parses = 0;
var set = new DeployedDriverSet(factory, parseDriverIds: b => { parses++; return Parse(b); });
await set.LoadAsync(TestContext.Current.CancellationToken);
await set.LoadAsync(TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
}
(await set.LoadAsync(TestContext.Current.CancellationToken))!.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<byte>())); // retention-emptied
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
(await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync(TestContext.Current.CancellationToken)).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");
}
}