Task #220 — Wire FOCAS into DriverFactoryRegistry bootstrap pipeline #199
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||||
|
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||||
|
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||||
|
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||||
|
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||||
|
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||||
|
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||||
|
/// <c>SharedSecret</c>.</item>
|
||||||
|
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||||
|
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||||
|
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||||
|
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||||
|
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||||
|
/// into <see cref="FocasDriverOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class FocasDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
public const string DriverTypeName = "FOCAS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||||
|
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
||||||
|
/// </summary>
|
||||||
|
public static void Register(DriverFactoryRegistry registry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, CreateInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
|
||||||
|
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
|
||||||
|
_ = ParseSeries(dto.Series);
|
||||||
|
|
||||||
|
var options = new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
|
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
|
||||||
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
|
DeviceName: d.DeviceName,
|
||||||
|
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||||
|
: [],
|
||||||
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
|
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||||
|
Name: t.Name ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
|
||||||
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||||
|
Address: t.Address ?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||||
|
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||||
|
Writable: t.Writable ?? true,
|
||||||
|
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||||
|
: [],
|
||||||
|
Probe = new FocasProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Probe?.Enabled ?? true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||||
|
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IFocasClientFactory BuildClientFactory(
|
||||||
|
FocasDriverConfigDto dto, string driverInstanceId)
|
||||||
|
{
|
||||||
|
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||||
|
return backend switch
|
||||||
|
{
|
||||||
|
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||||
|
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||||
|
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||||
|
"Expected one of: ipc, fwlib, unimplemented."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IpcFocasClientFactory BuildIpcFactory(
|
||||||
|
FocasDriverConfigDto dto, string driverInstanceId)
|
||||||
|
{
|
||||||
|
var pipeName = dto.PipeName
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||||
|
var sharedSecret = dto.SharedSecret
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||||
|
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||||
|
var series = ParseSeries(dto.Series);
|
||||||
|
|
||||||
|
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||||
|
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||||
|
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||||
|
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||||
|
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||||
|
// latency is identical to a fully-async factory.
|
||||||
|
return new IpcFocasClientFactory(
|
||||||
|
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||||
|
pipeName: pipeName,
|
||||||
|
sharedSecret: sharedSecret,
|
||||||
|
connectTimeout: connectTimeout,
|
||||||
|
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||||
|
series: series);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasCncSeries ParseSeries(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||||
|
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
|
||||||
|
? s
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
|
||||||
|
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
|
||||||
|
? dt
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
||||||
|
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal sealed class FocasDriverConfigDto
|
||||||
|
{
|
||||||
|
public string? Backend { get; init; }
|
||||||
|
public string? PipeName { get; init; }
|
||||||
|
public string? SharedSecret { get; init; }
|
||||||
|
public int? ConnectTimeoutMs { get; init; }
|
||||||
|
public string? Series { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public List<FocasDeviceDto>? Devices { get; init; }
|
||||||
|
public List<FocasTagDto>? Tags { get; init; }
|
||||||
|
public FocasProbeDto? Probe { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasDeviceDto
|
||||||
|
{
|
||||||
|
public string? HostAddress { get; init; }
|
||||||
|
public string? DeviceName { get; init; }
|
||||||
|
public string? Series { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasTagDto
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DeviceHostAddress { get; init; }
|
||||||
|
public string? Address { get; init; }
|
||||||
|
public string? DataType { get; init; }
|
||||||
|
public bool? Writable { get; init; }
|
||||||
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FocasProbeDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
public int? IntervalMs { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server;
|
using ZB.MOM.WW.OtOpcUa.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
@@ -98,6 +99,7 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
|||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||||
|
FocasDriverFactoryExtensions.Register(registry);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user