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"); } }