Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverFactoryExtensionsTests.cs
Joseph Doherty dfe3731c73 Task #220 — Wire FOCAS into DriverFactoryRegistry bootstrap pipeline
Closes the non-hardware gap surfaced in the #220 audit: FOCAS had full Tier-C
architecture (Driver.FOCAS + Driver.FOCAS.Host + Driver.FOCAS.Shared, supervisor,
post-mortem MMF, NSSM scripts, 239 tests) but no factory registration, so config-DB
DriverInstance rows of type "FOCAS" would fail at bootstrap with "unknown driver
type". Hardware-gated FwlibHostedBackend (real Fwlib32 P/Invoke inside the Host
process) stays deferred under #222 lab-rig.

Ships:
  - FocasDriverFactoryExtensions.Register(registry) mirroring the Galaxy pattern.
    JSON schema selects backend via "Backend" field:
      "ipc" (default) — IpcFocasClientFactory → named-pipe FocasIpcClient →
                        Driver.FOCAS.Host process (Tier-C isolation)
      "fwlib"         — direct in-process FwlibFocasClientFactory (P/Invoke)
      "unimplemented" — UnimplementedFocasClientFactory (fail-fast on use —
                        useful for staging DriverInstance rows pre-Host-deploy)
  - Devices / Tags / Probe / Timeout / Series feed into FocasDriverOptions.
    Series validated eagerly at top-level so typos fail at bootstrap, not first
    read. Tag DataType + Series enum values surface clear errors listing valid
    options.
  - Program.cs adds FocasDriverFactoryExtensions.Register alongside Galaxy.
  - Driver.FOCAS.csproj references Core (for DriverFactoryRegistry).
  - Server.csproj adds Driver.FOCAS ProjectReference so the factory type is
    reachable from Program.cs.

Tests: 13 new FocasDriverFactoryExtensionsTests covering: registry entry,
case-insensitive lookup, ipc backend with full config, ipc defaults, missing
PipeName/SharedSecret errors, fwlib backend short-path, unimplemented backend,
unknown-backend error, unknown-Series error, tag missing DataType, null/ws args,
duplicate-register throws.

Regression: 202 FOCAS + 13 FOCAS.Host + 24 FOCAS.Shared + 239 Server all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:08:25 -04:00

163 lines
5.8 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Task #220 — covers the DriverConfig JSON contract that
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
/// or CNC required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDriverFactoryExtensionsTests
{
[Fact]
public void Register_adds_FOCAS_entry_to_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("FOCAS").ShouldNotBeNull();
}
[Fact]
public void Register_is_case_insensitive_via_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("focas").ShouldNotBeNull();
registry.TryGet("Focas").ShouldNotBeNull();
}
[Fact]
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
{
const string json = """
{
"Backend": "ipc",
"PipeName": "OtOpcUaFocasHost",
"SharedSecret": "secret-for-test",
"ConnectTimeoutMs": 5000,
"Series": "Thirty_i",
"TimeoutMs": 3000,
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
],
"Tags": [
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100", "DataType": "Int32", "Writable": true }
]
}
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
driver.ShouldNotBeNull();
driver.DriverInstanceId.ShouldBe("focas-0");
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
{
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
const string json = """
{ "PipeName": "p", "SharedSecret": "s" }
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_ipc_backend_missing_PipeName_throws()
{
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
.Message.ShouldContain("PipeName");
}
[Fact]
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
{
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
.Message.ShouldContain("SharedSecret");
}
[Fact]
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
{
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
// natively on first use.
const string json = """{ "Backend": "fwlib" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
driver.DriverInstanceId.ShouldBe("focas-fwlib");
}
[Fact]
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
{
// Useful for staging DriverInstance rows in the config DB before the Host is
// actually deployed — the server boots but reads/writes surface clear errors.
const string json = """{ "Backend": "unimplemented" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
driver.DriverInstanceId.ShouldBe("focas-unimpl");
}
[Fact]
public void CreateInstance_unknown_backend_throws_with_expected_list()
{
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
.Message.ShouldContain("gibberish");
}
[Fact]
public void CreateInstance_rejects_unknown_Series()
{
const string json = """
{ "Backend": "fwlib", "Series": "NotARealSeries" }
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
.Message.ShouldContain("NotARealSeries");
}
[Fact]
public void CreateInstance_rejects_tag_with_missing_DataType()
{
const string json = """
{
"Backend": "fwlib",
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
}
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
.Message.ShouldContain("DataType");
}
[Fact]
public void CreateInstance_null_or_whitespace_args_rejected()
{
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
}
[Fact]
public void Register_twice_throws()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.Register(registry));
}
}