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: the instance IS , so the /// runtime's write gate dispatches to it. This is the seam the concrete return type exists for /// — the capability was added to the driver with no change to the factory, and a narrowed /// return would have left it implemented but never dispatched. /// /// /// Dispatchable is not enabled. The config below authors no write block, so this /// instance refuses every write with BadNotWritable — and the Agent's own /// AllowPut is a second, independent gate. See MTConnectWriteTests. /// [Fact] public void Registered_delegate_returns_an_instance_that_dispatches_writes() { var registry = new DriverFactoryRegistry(); MTConnectDriverFactoryExtensions.Register(registry); IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}"); (driver is IWritable).ShouldBeTrue("MTConnect must dispatch writes (the Agent AllowPut surface)"); } /// 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"); } /// /// The write block reaches the options — the opt-in half of the two-layer gate on /// write-back (the agent's own AllowPut is the other). Authored true arms it; /// an authored false and an omitted block are the same thing, and both leave the /// driver read-only. /// [Fact] public void Write_section_round_trips_and_defaults_to_disabled_when_absent() { MTConnectDriver.ParseOptions( "mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""") .Write.Enabled.ShouldBeTrue(); MTConnectDriver.ParseOptions( "mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": false } }""") .Write.Enabled.ShouldBeFalse(); MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000" }""") .Write.Enabled.ShouldBeFalse(); // The factory shares that parse, so an armed instance is authorable through it too. MTConnectDriverFactoryExtensions.CreateInstance( "mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""") .DriverInstanceId.ShouldBe("mt1"); } /// /// Driver.MTConnect-004: the document the AdminUI's MTConnectDriverForm emits arms /// write-back through this parse. The literal below is the form's own output for a /// write-enabled instance — key spelling, casing and block nesting included — since the /// AdminUI cannot be referenced from a driver test project; the form side of the same /// contract is pinned by MTConnectDriverFormModelTests, which asserts these exact /// keys come out of BuildConfigJson. /// [Fact] public void The_document_the_AdminUI_form_emits_arms_write_back() { const string formEmitted = """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; var options = MTConnectDriver.ParseOptions("mt1", formEmitted); options.Write.Enabled.ShouldBeTrue(); options.AgentUri.ShouldBe("http://agent:5000"); // The same document with the switch off is read-only again — the form writes the block in // both states, so this is the round trip an operator turning the feature back off performs. MTConnectDriver.ParseOptions( "mt1", formEmitted.Replace("""enabled":true}""", """enabled":false}""", StringComparison.Ordinal)) .Write.Enabled.ShouldBeFalse(); } /// /// A malformed write block fails exactly as its siblings do — the whole /// document is one JsonSerializer.Deserialize, so a non-boolean enabled faults /// the parse rather than silently defaulting to off. The probe leg is the control: it /// pins that this is the surrounding convention and not stricter behaviour invented here. /// [Fact] public void Malformed_write_block_faults_exactly_like_the_sibling_probe_block() { Should.Throw(() => MTConnectDriver.ParseOptions( "mt1", """{ "agentUri": "http://x:5000", "probe": { "enabled": "yes" } }""")); Should.Throw(() => MTConnectDriver.ParseOptions( "mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": "yes" } }""")); // A null block is not malformed — the same "omitted means default" reading the sibling // blocks get, since every DTO property is nullable. MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000", "write": null }""") .Write.Enabled.ShouldBeFalse(); } }