diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs new file mode 100644 index 00000000..34797fa7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs @@ -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; + +/// +/// Static factory registration helper for . The Host registers it +/// once at startup; the bootstrapper then materialises MTConnect DriverInstance rows from +/// the deployed configuration into live driver instances. Mirrors +/// ModbusDriverFactoryExtensions / FocasDriverFactoryExtensions. +/// +/// +/// +/// There is exactly one parser for the config document. This factory does not carry a +/// config DTO of its own — it delegates to , which +/// the driver itself must own because the runtime delivers a config change to a live +/// instance (DriverInstanceActor assigns its driver once and calls +/// ReinitializeAsync(newJson) 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. +/// +/// +/// Construction opens nothing. The Wave-0 universal discovery browser builds a +/// throwaway instance through this factory purely to read +/// ITagDiscovery.SupportsOnlineDiscovery, and never initializes it — so a factory +/// that dialled the Agent (or that pre-built an ) would +/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The +/// agent-client factory is therefore left null: builds the +/// production client inside InitializeAsync. +/// +/// +/// The instance is returned as its concrete type. The runtime resolves every optional +/// capability (, , +/// , , +/// ) by pattern-matching the object +/// 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 IWritable: the MTConnect Agent surface is read-only. +/// +/// +public static class MTConnectDriverFactoryExtensions +{ + /// + /// The DriverInstance.DriverType value this factory answers to. + /// + /// + /// Kept identical to — 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 DriverTypeNames.MTConnect + /// 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. + /// + public const string DriverTypeName = "MTConnect"; + + /// + /// Register the MTConnect factory with the driver registry. The optional + /// is captured at registration time and used to construct + /// an per driver instance — without it the driver runs + /// with the null logger (tests and standalone callers stay unchanged). + /// + /// The driver factory registry to register with. + /// Optional logger factory for creating loggers per driver instance. + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Public for the Server-side bootstrapper + test consumers. + /// The unique identifier for the driver instance. + /// The driver's DriverConfig JSON. + /// The constructed instance. + public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + /// Logger-aware overload — used by 's closure when wired through DI. + /// The unique identifier for the driver instance. + /// The driver's DriverConfig JSON. + /// Optional logger factory for creating loggers per driver instance. + /// The constructed instance. + /// + /// or is null or blank. + /// + /// + /// The config document is unparseable, deserialises to null, omits the required + /// agentUri, 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 CanBrowse catches factory exceptions so a + /// half-authored config merely disables the address picker. + /// + 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()); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs new file mode 100644 index 00000000..49dea03c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs @@ -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; + +/// +/// Task 15 — : the seam that turns a +/// DriverInstance row's DriverConfig JSON into a live . +/// +/// Three properties carry the weight here. (1) One parser. The factory delegates to +/// MTConnectDriver.ParseOptions rather than deserializing a second DTO, so the config +/// document has a single authority and the runtime's ReinitializeAsync path cannot +/// drift from the bootstrap path. (2) Connection-free construction. The Wave-0 +/// universal browser builds a throwaway instance purely to read +/// SupportsOnlineDiscovery, so a factory that dialled would open a socket per AdminUI +/// browse render against a possibly-unreachable Agent. (3) No dropped capability. The +/// registry hands the runtime an ; every capability is resolved by +/// pattern-matching the concrete instance, so the interface set on the object the factory +/// returns IS the dispatch surface. +/// +/// +public sealed class MTConnectFactoryTests +{ + private const uint Good = 0x00000000u; + private const uint BadWaitingForInitialData = 0x80320000u; + private const uint BadNodeIdUnknown = 0x80340000u; + + // ----------------------------------------------------------------- construction + + /// The plan's headline case: a one-key document is a complete MTConnect config. + [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"); + } + + /// + /// The factory's own type name and the instance's must be + /// the same string, or a registered factory would materialise a driver the runtime looks up + /// under a different key. + /// + [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 + + /// + /// No agentUri means there is no Agent to talk to. Throwing is correct: the browser's + /// CanBrowse 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. + /// + [Fact] + public void Factory_rejects_config_without_agentUri() + => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}")); + + /// + /// A whitespace-only agentUri is the same absence spelled differently — it must not + /// slip past the required check and become a driver dialling an empty URI. + /// + [Fact] + public void Factory_rejects_a_blank_agentUri() + => Should.Throw( + () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\" \"}")); + + /// + /// 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. + /// + [Fact] + public void Factory_rejects_unparseable_json_rather_than_defaulting() + { + var ex = Should.Throw( + () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json")); + + ex.Message.ShouldContain("mt1"); + } + + /// A JSON null document is not a config either. + [Fact] + public void Factory_rejects_a_null_json_document() + => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null")); + + /// An empty config string has nothing to build from; the id likewise. + [Theory] + [InlineData("mt1", "")] + [InlineData("mt1", " ")] + [InlineData("", "{\"agentUri\":\"http://a:5000\"}")] + public void Factory_rejects_empty_arguments(string id, string json) + => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json)); + + // ----------------------------------------------------------------- connection-free + + /// + /// The factory opens no socket. 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. + /// + [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(); + } + } + + /// + /// 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. + /// + [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 + + /// + /// 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. + /// + [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"); + } + + /// + /// The anti-dormancy pin. 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 + /// the registry delegate returns, which is exactly what the bootstrapper holds. + /// + [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(); + (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"); + } + + /// + /// The other half of the capability pin: MTConnect's Agent surface is read-only, so the + /// instance must NOT be . If it ever became writable, the write gate + /// would start offering an operation the protocol cannot honour. + /// + [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"); + } + + /// Registering into a null registry is a wiring bug, not a silent no-op. + [Fact] + public void Register_rejects_a_null_registry() + => Should.Throw(() => MTConnectDriverFactoryExtensions.Register(null!)); + + // ----------------------------------------------------------------- config round-trip + + /// + /// An authored tags array reaches options.Tags. Observed through the + /// observation index the constructor builds from those options: an authored id answers + /// BadWaitingForInitialData ("subscribed, no value yet"), an unauthored one answers + /// BadNodeIdUnknown. Status codes are literals so a wrong constant cannot be masked + /// by both sides sharing one symbol. + /// + [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); + } + + /// + /// An enum authored as a name parses to that member — proved by behaviour, not by + /// reading the option back: a Float64 tag coerces the Agent's text into a real + /// , whereas the enum's member 0 (Boolean) or a defaulted + /// String would not. + /// + [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().ShouldBe(12.5d); + } + + /// + /// The enum-serialization trap. The factory deliberately carries no + /// JsonStringEnumConverter: 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 (Boolean), which would mis-coerce every value of that tag. + /// + [Fact] + public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero() + => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance( + "mt1", + """ + { "agentUri": "http://a:5000", + "tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] } + """)); + + /// An enum name that names no member is an authoring error, reported with the field. + [Fact] + public void Unknown_enum_name_faults_naming_the_offending_tag() + { + var ex = Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance( + "mt1", + """ + { "agentUri": "http://a:5000", + "tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] } + """)); + + ex.Message.ShouldContain("dev1_pos"); + } + + /// + /// 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 reconnect block changes the backoff the driver would apply. + /// + [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"); + } +}