feat(mtconnect): driver factory delegating to the single config parser (Task 15)
The factory carries NO config DTO of its own: CreateInstance delegates to MTConnectDriver.ParseOptions, the single authority for the config document. A second DTO would drift from the parse the runtime's ReinitializeAsync path uses, and the drift would only surface at deploy time. Construction is connection-free (agentClientFactory: null) because the Wave-0 universal browser builds a throwaway instance per browse probe, and the returned instance is never narrowed — its five capability interfaces ARE the runtime's dispatch surface. Pinned by tests asserting all five plus the deliberate absence of IWritable. DriverTypeName is the literal "MTConnect"; Task 16 adds DriverTypeNames.MTConnect and the host registration that must equal it.
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="MTConnectDriver"/>. The Host registers it
|
||||||
|
/// once at startup; the bootstrapper then materialises MTConnect <c>DriverInstance</c> rows from
|
||||||
|
/// the deployed configuration into live driver instances. Mirrors
|
||||||
|
/// <c>ModbusDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>There is exactly one parser for the config document.</b> This factory does not carry a
|
||||||
|
/// config DTO of its own — it delegates to <see cref="MTConnectDriver.ParseOptions"/>, which
|
||||||
|
/// the driver itself must own because the runtime delivers a config <i>change</i> to a live
|
||||||
|
/// instance (<c>DriverInstanceActor</c> assigns its driver once and calls
|
||||||
|
/// <c>ReinitializeAsync(newJson)</c> thereafter). A second DTO here would drift from that
|
||||||
|
/// one silently, and the drift would first show up at deploy time as an operator's edit
|
||||||
|
/// being applied differently by the two paths — or not at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Construction opens nothing.</b> The Wave-0 universal discovery browser builds a
|
||||||
|
/// throwaway instance through this factory purely to read
|
||||||
|
/// <c>ITagDiscovery.SupportsOnlineDiscovery</c>, and never initializes it — so a factory
|
||||||
|
/// that dialled the Agent (or that pre-built an <see cref="IMTConnectAgentClient"/>) would
|
||||||
|
/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
|
||||||
|
/// agent-client factory is therefore left null: <see cref="MTConnectDriver"/> builds the
|
||||||
|
/// production client inside <c>InitializeAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
|
||||||
|
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
|
||||||
|
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
|
||||||
|
/// <see cref="IRediscoverable"/>) by pattern-matching the object
|
||||||
|
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
|
||||||
|
/// narrowed return is how a capability ends up implemented but never dispatched. There is
|
||||||
|
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class MTConnectDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>DriverInstance.DriverType</c> value this factory answers to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Kept identical to <see cref="MTConnectDriver.DriverType"/> — the registry key and the
|
||||||
|
/// instance's self-reported type must agree or the runtime would look the driver up under a
|
||||||
|
/// name it never registered. Task 16 adds the matching <c>DriverTypeNames.MTConnect</c>
|
||||||
|
/// constant (the repo's convention is that dispatch maps key off those constants) and wires
|
||||||
|
/// the Host registration + guard test; this literal is what that constant must equal.
|
||||||
|
/// </remarks>
|
||||||
|
public const string DriverTypeName = "MTConnect";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the MTConnect factory with the driver registry. The optional
|
||||||
|
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct
|
||||||
|
/// an <see cref="ILogger{TCategoryName}"/> per driver instance — without it the driver runs
|
||||||
|
/// with the null logger (tests and standalone callers stay unchanged).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||||
|
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
|
||||||
|
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
|
||||||
|
public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||||
|
|
||||||
|
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||||
|
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is null or blank.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// The config document is unparseable, deserialises to null, omits the required
|
||||||
|
/// <c>agentUri</c>, or carries an unauthorable enum/tag value. Throwing is correct at this
|
||||||
|
/// seam: a deployment carrying such a row must fail rather than register a driver pointed at
|
||||||
|
/// nothing, and the discovery browser's <c>CanBrowse</c> catches factory exceptions so a
|
||||||
|
/// half-authored config merely disables the address picker.
|
||||||
|
/// </exception>
|
||||||
|
public static MTConnectDriver CreateInstance(
|
||||||
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
// The single config authority — see the class remarks for why this is not a second DTO.
|
||||||
|
// Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
|
||||||
|
// there means "no config supplied, keep the constructor options", but this factory HAS no
|
||||||
|
// constructor options to keep, so an empty document is simply a config with no agentUri and
|
||||||
|
// must fault.
|
||||||
|
var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
|
||||||
|
|
||||||
|
// Named arguments deliberately: the ctor's trailing parameters are all optional, so a
|
||||||
|
// future insertion could silently re-bind a positional argument to the wrong one.
|
||||||
|
return new MTConnectDriver(
|
||||||
|
options,
|
||||||
|
driverInstanceId,
|
||||||
|
agentClientFactory: null,
|
||||||
|
logger: loggerFactory?.CreateLogger<MTConnectDriver>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 15 — <see cref="MTConnectDriverFactoryExtensions"/>: the seam that turns a
|
||||||
|
/// <c>DriverInstance</c> row's <c>DriverConfig</c> JSON into a live <see cref="MTConnectDriver"/>.
|
||||||
|
/// <para>
|
||||||
|
/// Three properties carry the weight here. (1) <b>One parser.</b> The factory delegates to
|
||||||
|
/// <c>MTConnectDriver.ParseOptions</c> rather than deserializing a second DTO, so the config
|
||||||
|
/// document has a single authority and the runtime's <c>ReinitializeAsync</c> path cannot
|
||||||
|
/// drift from the bootstrap path. (2) <b>Connection-free construction.</b> The Wave-0
|
||||||
|
/// universal browser builds a throwaway instance purely to read
|
||||||
|
/// <c>SupportsOnlineDiscovery</c>, so a factory that dialled would open a socket per AdminUI
|
||||||
|
/// browse render against a possibly-unreachable Agent. (3) <b>No dropped capability.</b> The
|
||||||
|
/// registry hands the runtime an <see cref="IDriver"/>; every capability is resolved by
|
||||||
|
/// pattern-matching the concrete instance, so the interface set on the object the factory
|
||||||
|
/// returns IS the dispatch surface.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MTConnectFactoryTests
|
||||||
|
{
|
||||||
|
private const uint Good = 0x00000000u;
|
||||||
|
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||||
|
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- construction
|
||||||
|
|
||||||
|
/// <summary>The plan's headline case: a one-key document is a complete MTConnect config.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Factory_builds_a_driver_from_minimal_config()
|
||||||
|
{
|
||||||
|
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||||
|
|
||||||
|
d.DriverType.ShouldBe("MTConnect");
|
||||||
|
d.DriverInstanceId.ShouldBe("mt1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The factory's own type name and the instance's <see cref="IDriver.DriverType"/> must be
|
||||||
|
/// the same string, or a registered factory would materialise a driver the runtime looks up
|
||||||
|
/// under a different key.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DriverTypeName_matches_the_instances_DriverType()
|
||||||
|
{
|
||||||
|
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||||
|
|
||||||
|
MTConnectDriverFactoryExtensions.DriverTypeName.ShouldBe("MTConnect");
|
||||||
|
d.DriverType.ShouldBe(MTConnectDriverFactoryExtensions.DriverTypeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- rejection
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No <c>agentUri</c> means there is no Agent to talk to. Throwing is correct: the browser's
|
||||||
|
/// <c>CanBrowse</c> catches factory exceptions (a half-authored config just disables the
|
||||||
|
/// picker), and a deployment carrying such a row must fail rather than register a driver
|
||||||
|
/// pointed at nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Factory_rejects_config_without_agentUri()
|
||||||
|
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A whitespace-only <c>agentUri</c> is the same absence spelled differently — it must not
|
||||||
|
/// slip past the required check and become a driver dialling an empty URI.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Factory_rejects_a_blank_agentUri()
|
||||||
|
=> Should.Throw<InvalidOperationException>(
|
||||||
|
() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\" \"}"));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unparseable JSON faults loudly rather than silently yielding a defaulted driver — the
|
||||||
|
/// "empty bytes are not an empty configuration" rule (#485) applied at the factory seam.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Factory_rejects_unparseable_json_rather_than_defaulting()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(
|
||||||
|
() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json"));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("mt1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A JSON <c>null</c> document is not a config either.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Factory_rejects_a_null_json_document()
|
||||||
|
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null"));
|
||||||
|
|
||||||
|
/// <summary>An empty config string has nothing to build from; the id likewise.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("mt1", "")]
|
||||||
|
[InlineData("mt1", " ")]
|
||||||
|
[InlineData("", "{\"agentUri\":\"http://a:5000\"}")]
|
||||||
|
public void Factory_rejects_empty_arguments(string id, string json)
|
||||||
|
=> Should.Throw<ArgumentException>(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- connection-free
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The factory opens no socket.</b> Asserted directly: a real listener is bound, the
|
||||||
|
/// driver is built against its address, and nothing ever arrives on the accept queue. A
|
||||||
|
/// regression here would make every AdminUI browse render dial the Agent.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateInstance_opens_no_socket_to_the_configured_agent()
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
|
||||||
|
"mt1", $"{{\"agentUri\":\"http://127.0.0.1:{port}\"}}");
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
driver.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Generous grace for a connect that a background thread might land late.
|
||||||
|
await Task.Delay(250, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
listener.Pending().ShouldBeFalse(
|
||||||
|
"MTConnectDriverFactoryExtensions.CreateInstance dialled the Agent — the browser " +
|
||||||
|
"builds a throwaway instance per browse probe, so construction must open nothing.");
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
listener.Stop();
|
||||||
|
listener.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same property against an address that can never answer (TEST-NET-1, RFC 5737):
|
||||||
|
/// construction returns promptly instead of blocking on a connect that will only end in a
|
||||||
|
/// timeout.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void CreateInstance_returns_immediately_for_a_black_hole_agent()
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
|
||||||
|
"mt1", "{\"agentUri\":\"http://192.0.2.1:5000\"}");
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
driver.DriverInstanceId.ShouldBe("mt1");
|
||||||
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- registry wiring
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="MTConnectDriverFactoryExtensions.Register"/> puts the type in the registry
|
||||||
|
/// under the same key the bootstrapper looks up, and the registered delegate — not just the
|
||||||
|
/// static helper — builds a working instance.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Register_adds_the_type_and_the_registered_delegate_builds_a_driver()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
|
||||||
|
MTConnectDriverFactoryExtensions.Register(registry);
|
||||||
|
|
||||||
|
registry.RegisteredTypes.ShouldContain("MTConnect");
|
||||||
|
|
||||||
|
var factory = registry.TryGet("MTConnect");
|
||||||
|
factory.ShouldNotBeNull();
|
||||||
|
|
||||||
|
var driver = factory("mt-from-registry", "{\"agentUri\":\"http://a:5000\"}");
|
||||||
|
|
||||||
|
driver.DriverInstanceId.ShouldBe("mt-from-registry");
|
||||||
|
driver.DriverType.ShouldBe("MTConnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The anti-dormancy pin.</b> The runtime resolves every optional capability by
|
||||||
|
/// pattern-matching the instance the registry hands back, so a capability the driver
|
||||||
|
/// implements but the factory's return path hides would be implemented-yet-never-dispatched
|
||||||
|
/// — a failure shape this repo has shipped twice. Asserted on the <see cref="IDriver"/>
|
||||||
|
/// the registry delegate returns, which is exactly what the bootstrapper holds.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Registered_delegate_returns_an_instance_carrying_every_capability()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
MTConnectDriverFactoryExtensions.Register(registry);
|
||||||
|
|
||||||
|
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||||
|
|
||||||
|
driver.ShouldBeOfType<MTConnectDriver>();
|
||||||
|
(driver is IReadable).ShouldBeTrue("MTConnect must dispatch reads");
|
||||||
|
(driver is ISubscribable).ShouldBeTrue("MTConnect must dispatch subscriptions");
|
||||||
|
(driver is ITagDiscovery).ShouldBeTrue("MTConnect must dispatch browse/discovery");
|
||||||
|
(driver is IHostConnectivityProbe).ShouldBeTrue("MTConnect must dispatch host connectivity");
|
||||||
|
(driver is IRediscoverable).ShouldBeTrue("MTConnect must dispatch rediscovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
|
||||||
|
/// instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
|
||||||
|
/// would start offering an operation the protocol cannot honour.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Registered_delegate_returns_an_instance_that_is_not_writable()
|
||||||
|
{
|
||||||
|
var registry = new DriverFactoryRegistry();
|
||||||
|
MTConnectDriverFactoryExtensions.Register(registry);
|
||||||
|
|
||||||
|
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
|
||||||
|
|
||||||
|
(driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Register_rejects_a_null_registry()
|
||||||
|
=> Should.Throw<ArgumentNullException>(() => MTConnectDriverFactoryExtensions.Register(null!));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- config round-trip
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An authored <c>tags</c> array reaches <c>options.Tags</c>. Observed through the
|
||||||
|
/// observation index the constructor builds from those options: an authored id answers
|
||||||
|
/// <c>BadWaitingForInitialData</c> ("subscribed, no value yet"), an unauthored one answers
|
||||||
|
/// <c>BadNodeIdUnknown</c>. Status codes are literals so a wrong constant cannot be masked
|
||||||
|
/// by both sides sharing one symbol.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Authored_tags_round_trip_into_the_driver_options()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"agentUri": "http://a:5000",
|
||||||
|
"tags": [
|
||||||
|
{ "fullName": "dev1_pos", "driverDataType": "Float64" },
|
||||||
|
{ "fullName": "dev1_execution", "driverDataType": "String" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var driver = MTConnectDriverFactoryExtensions.CreateInstance("mt1", json);
|
||||||
|
|
||||||
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||||
|
driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||||
|
driver.ObservationIndex.Get("dev1_never_authored").StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An enum authored as a <b>name</b> parses to that member — proved by behaviour, not by
|
||||||
|
/// reading the option back: a <c>Float64</c> tag coerces the Agent's text into a real
|
||||||
|
/// <see cref="double"/>, whereas the enum's member 0 (<c>Boolean</c>) or a defaulted
|
||||||
|
/// <c>String</c> would not.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Enum_fields_authored_as_names_parse_to_that_member()
|
||||||
|
{
|
||||||
|
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
|
||||||
|
"mt1",
|
||||||
|
"""
|
||||||
|
{ "agentUri": "http://a:5000",
|
||||||
|
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64" } ] }
|
||||||
|
""");
|
||||||
|
|
||||||
|
driver.ObservationIndex.Apply(new MTConnectStreamsResult(
|
||||||
|
InstanceId: 1L,
|
||||||
|
NextSequence: 2L,
|
||||||
|
FirstSequence: 1L,
|
||||||
|
Observations: [new MTConnectObservation(
|
||||||
|
"dev1_pos", "12.5", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc), false)]));
|
||||||
|
|
||||||
|
var snap = driver.ObservationIndex.Get("dev1_pos");
|
||||||
|
|
||||||
|
snap.StatusCode.ShouldBe(Good);
|
||||||
|
snap.Value.ShouldBeOfType<double>().ShouldBe(12.5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The enum-serialization trap.</b> The factory deliberately carries no
|
||||||
|
/// <c>JsonStringEnumConverter</c>: a numerically-serialized enum — the shape an AdminUI page
|
||||||
|
/// that serialized the enum by value would emit — must fault at deploy rather than silently
|
||||||
|
/// landing on member 0 (<c>Boolean</c>), which would mis-coerce every value of that tag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero()
|
||||||
|
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
|
||||||
|
"mt1",
|
||||||
|
"""
|
||||||
|
{ "agentUri": "http://a:5000",
|
||||||
|
"tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] }
|
||||||
|
"""));
|
||||||
|
|
||||||
|
/// <summary>An enum name that names no member is an authoring error, reported with the field.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_enum_name_faults_naming_the_offending_tag()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
|
||||||
|
"mt1",
|
||||||
|
"""
|
||||||
|
{ "agentUri": "http://a:5000",
|
||||||
|
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] }
|
||||||
|
"""));
|
||||||
|
|
||||||
|
ex.Message.ShouldContain("dev1_pos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scalar knobs reach the options too — the factory must not quietly drop a section it does
|
||||||
|
/// not itself read. Proved through the one seam a non-initialized driver exposes: an
|
||||||
|
/// authored <c>reconnect</c> block changes the backoff the driver would apply.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Reconnect_and_probe_sections_round_trip_into_the_driver_options()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"agentUri": "http://a:5000",
|
||||||
|
"deviceName": "dev1",
|
||||||
|
"requestTimeoutMs": 1234,
|
||||||
|
"reconnect": { "minBackoffMs": 500, "maxBackoffMs": 4000, "backoffMultiplier": 3.0 },
|
||||||
|
"probe": { "enabled": false, "intervalMs": 7000, "timeoutMs": 900 }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var options = MTConnectDriver.ParseOptions("mt1", json);
|
||||||
|
|
||||||
|
options.AgentUri.ShouldBe("http://a:5000");
|
||||||
|
options.DeviceName.ShouldBe("dev1");
|
||||||
|
options.RequestTimeoutMs.ShouldBe(1234);
|
||||||
|
options.Reconnect.MinBackoffMs.ShouldBe(500);
|
||||||
|
options.Reconnect.MaxBackoffMs.ShouldBe(4000);
|
||||||
|
options.Reconnect.BackoffMultiplier.ShouldBe(3.0);
|
||||||
|
options.Probe.Enabled.ShouldBeFalse();
|
||||||
|
options.Probe.Interval.ShouldBe(TimeSpan.FromMilliseconds(7000));
|
||||||
|
options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(900));
|
||||||
|
|
||||||
|
// …and the factory is built on exactly that parse, not a second DTO: the same document
|
||||||
|
// through the factory must produce an instance, and the same rejection rules (asserted
|
||||||
|
// above) hold for both entry points.
|
||||||
|
MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user