Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs
T
Joseph Doherty 298ad04fba feat(adminui): author MTConnect write-back on the driver form (Driver.MTConnect-004)
IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.

- Write-back section: switch + form text stating it is observation injection, that the
  Agent's AllowPut is an independent second gate, and that it does not by itself make a
  node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
  states — an omitted block would leave a previously-authored true standing, so the switch
  could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.

No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
2026-08-10 08:07:36 -04:00

433 lines
20 KiB
C#

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: the instance IS <see cref="IWritable"/>, 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.
/// </summary>
/// <remarks>
/// <b>Dispatchable is not enabled.</b> The config below authors no <c>write</c> block, so this
/// instance refuses every write with <c>BadNotWritable</c> — and the Agent's own
/// <c>AllowPut</c> is a second, independent gate. See <c>MTConnectWriteTests</c>.
/// </remarks>
[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)");
}
/// <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");
}
/// <summary>
/// The <c>write</c> block reaches the options — the opt-in half of the two-layer gate on
/// write-back (the agent's own <c>AllowPut</c> is the other). Authored <c>true</c> arms it;
/// an authored <c>false</c> and an omitted block are the same thing, and both leave the
/// driver read-only.
/// </summary>
[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");
}
/// <summary>
/// Driver.MTConnect-004: the document the AdminUI's <c>MTConnectDriverForm</c> 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 <c>MTConnectDriverFormModelTests</c>, which asserts these exact
/// keys come out of <c>BuildConfigJson</c>.
/// </summary>
[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();
}
/// <summary>
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
/// the parse rather than silently defaulting to off. The <c>probe</c> leg is the control: it
/// pins that this is the surrounding convention and not stricter behaviour invented here.
/// </summary>
[Fact]
public void Malformed_write_block_faults_exactly_like_the_sibling_probe_block()
{
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "probe": { "enabled": "yes" } }"""));
Should.Throw<InvalidOperationException>(() => 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();
}
}