Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs
Joseph Doherty 3d78033ea4 Driver-instance bootstrap pipeline (#248) — DriverInstance rows materialise as live IDriver instances
Closes the gap surfaced by Phase 7 live smoke (#240): DriverInstance rows in
the central config DB had no path to materialise as live IDriver instances in
DriverHost, so virtual-tag scripts read BadNodeIdUnknown for every tag.

## DriverFactoryRegistry (Core.Hosting)
Process-singleton type-name → factory map. Each driver project's static
Register call pre-loads its factory at Program.cs startup; the bootstrapper
looks up by DriverInstance.DriverType + invokes with (DriverInstanceId,
DriverConfig JSON). Case-insensitive; duplicate-type registration throws.

## GalaxyProxyDriverFactoryExtensions.Register (Driver.Galaxy.Proxy)
Static helper — no Microsoft.Extensions.DependencyInjection dep, keeps the
driver project free of DI machinery. Parses DriverConfig JSON for PipeName +
SharedSecret + ConnectTimeoutMs. DriverInstanceId from the row wins over JSON
per the schema's UX_DriverInstance_Generation_LogicalId.

## DriverInstanceBootstrapper (Server)
After NodeBootstrap loads the published generation: queries DriverInstance
rows scoped to that generation, looks up the factory per row, constructs +
DriverHost.RegisterAsync (which calls InitializeAsync). Per plan decision
#12 (driver isolation), failure of one driver doesn't prevent others —
logs ERR + continues + returns the count actually registered. Unknown
DriverType (factory not registered) logs WRN + skips so a missing-assembly
deployment doesn't take down the whole server.

## Wired into OpcUaServerService.ExecuteAsync
After NodeBootstrap.LoadCurrentGenerationAsync, before
PopulateEquipmentContentAsync + Phase7Composer.PrepareAsync. The Phase 7
chain now sees a populated DriverHost so CachedTagUpstreamSource has an
upstream feed.

## Live evidence on the dev box
Re-ran the Phase 7 smoke from task #240. Pre-#248 vs post-#248:
  Equipment namespace snapshots loaded for 0/0 driver(s)  ← before
  Equipment namespace snapshots loaded for 1/1 driver(s)  ← after

Galaxy.Host pipe ACL denied our SID (env-config issue documented in
docs/ServiceHosting.md, NOT a code issue) — the bootstrapper logged it as
"failed to initialize, driver state will reflect Faulted" and continued past
the failure exactly per plan #12. The rest of the pipeline (Equipment walker
+ Phase 7 composer) ran to completion.

## Tests — 5 new DriverFactoryRegistryTests
Register + TryGet round-trip, case-insensitive lookup, duplicate-type throws,
null-arg guards, RegisteredTypes snapshot. Pure functions; no DI/DB needed.
The bootstrapper's DB-query path is exercised by the live smoke (#240) which
operators run before each release.
2026-04-20 22:49:25 -04:00

74 lines
2.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// Task #248 — covers the <see cref="DriverFactoryRegistry"/> contract that
/// <see cref="DriverInstanceBootstrapper"/> consumes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverFactoryRegistryTests
{
private static IDriver FakeDriver(string id, string config) => new FakeIDriver(id);
[Fact]
public void Register_then_TryGet_returns_factory()
{
var r = new DriverFactoryRegistry();
r.Register("MyDriver", FakeDriver);
r.TryGet("MyDriver").ShouldNotBeNull();
r.TryGet("Nope").ShouldBeNull();
}
[Fact]
public void Register_is_case_insensitive()
{
var r = new DriverFactoryRegistry();
r.Register("Galaxy", FakeDriver);
r.TryGet("galaxy").ShouldNotBeNull();
r.TryGet("GALAXY").ShouldNotBeNull();
}
[Fact]
public void Register_duplicate_type_throws()
{
var r = new DriverFactoryRegistry();
r.Register("Galaxy", FakeDriver);
Should.Throw<InvalidOperationException>(() => r.Register("Galaxy", FakeDriver));
}
[Fact]
public void Register_null_args_rejected()
{
var r = new DriverFactoryRegistry();
Should.Throw<ArgumentException>(() => r.Register("", FakeDriver));
Should.Throw<ArgumentNullException>(() => r.Register("X", null!));
}
[Fact]
public void RegisteredTypes_returns_snapshot()
{
var r = new DriverFactoryRegistry();
r.Register("A", FakeDriver);
r.Register("B", FakeDriver);
r.RegisteredTypes.ShouldContain("A");
r.RegisteredTypes.ShouldContain("B");
}
private sealed class FakeIDriver(string id) : IDriver
{
public string DriverInstanceId => id;
public string DriverType => "Fake";
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
public long GetMemoryFootprint() => 0;
}
}