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>
163 lines
5.8 KiB
C#
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));
|
|
}
|
|
}
|