using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// 's leg — the batch contract, the per-slot /// refusals, and the one thing the driver has to get right before the Agent is even dialled: which /// device a DataItem belongs to. /// /// /// /// The write itself is the live gate's job. Everything below stops at the /// seam and asserts what the driver asked the Agent /// for (), never that a fake was called for its own sake — /// the same philosophy as MqttDriverWriteTests. What IS worth pinning offline is /// everything that decides whether a write should reach an Agent at all, because that is where /// a silent no-op would hide. /// /// /// A write is observation injection, not machine command (design §2.2): it sets what the /// Agent reports for a DataItem, and the next adapter update overwrites it. That is why the /// feature is off at two independent gates — the Agent's AllowPut, and this driver's /// write.enabled — and why the disabled leg below is the first test in the file. /// /// public sealed class MTConnectWriteTests { private const string AgentUri = "http://fixture-agent:5000"; /// The device the canned Fixtures/probe.xml declares — id="dev1" name="VMC-3Axis". private const string FixtureDeviceName = "VMC-3Axis"; private const string PositionDataItemId = "dev1_pos"; private const string ConditionDataItemId = "dev1_system_cond"; private const string PositionRawPath = "Plant/MTConnect/dev1/Position"; private const string ConditionRawPath = "Plant/MTConnect/dev1/SystemCondition"; private const string UnknownRawPath = "Plant/MTConnect/dev1/NotATag"; private const uint Good = 0x00000000u; private const uint BadNodeIdUnknown = 0x80340000u; private const uint BadNotWritable = 0x803B0000u; private const uint BadTypeMismatch = 0x80740000u; private const uint BadCommunicationError = 0x80050000u; private const uint BadNotConnected = 0x808A0000u; private static CancellationToken Ct => TestContext.Current.CancellationToken; /// One authored raw tag bound to a DataItem id, as the browse-commit flow writes it. private static RawTagEntry Entry(string rawPath, string dataItemId) => new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1"); private static MTConnectDriverOptions Opts( bool writeEnabled = true, string? deviceName = null, params RawTagEntry[] rawTags) => new() { AgentUri = AgentUri, DeviceName = deviceName, Tags = [], RawTags = rawTags, Write = new MTConnectWriteOptions { Enabled = writeEnabled }, }; private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( MTConnectDriverOptions options, RecordingDriverLogger? logger = null) { var client = CannedAgentClient.FromFixtures(); return (new MTConnectDriver(options, "mt1", _ => client, logger), client); } private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedAsync( MTConnectDriverOptions options, RecordingDriverLogger? logger = null) { var (driver, client) = NewDriver(options, logger); await driver.InitializeAsync("{}", Ct); return (driver, client); } /// The ordinary rig: write on, one writable position tag and the fixture's CONDITION item. private static Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableDriverAsync( RecordingDriverLogger? logger = null) => InitializedAsync( Opts( writeEnabled: true, deviceName: null, Entry(PositionRawPath, PositionDataItemId), Entry(ConditionRawPath, ConditionDataItemId)), logger); // ---- the capability itself ---- [Fact] public void MTConnectDriver_implements_IWritable() // The interface's presence IS the feature: without it the runtime's capability dispatch never // offers this driver a write, whatever a node advertises. => NewDriver(Opts()).Driver.ShouldBeAssignableTo(); // ---- the two independent gates ---- [Fact] public async Task A_driver_with_write_disabled_refuses_every_slot_without_dialling_the_agent() { // The default. An existing deployment that never authors the block keeps today's read-only // behaviour EXACTLY — including making no request an Agent could log or accept. var (driver, client) = await InitializedAsync( Opts(writeEnabled: false, deviceName: null, Entry(PositionRawPath, PositionDataItemId))); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results.Count.ShouldBe(1); results[0].StatusCode.ShouldBe(BadNotWritable); client.Writes.ShouldBeEmpty(); } [Fact] public async Task A_write_before_initialize_reports_not_connected() { // Matches ReadAsync: "this driver is not running" is the only true statement available, and it // is NOT the same operator problem as an Agent that answered and refused. var (driver, _) = NewDriver(Opts(true, null, Entry(PositionRawPath, PositionDataItemId))); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results[0].StatusCode.ShouldBe(BadNotConnected); } [Fact] public async Task A_write_after_shutdown_reports_not_connected() { var (driver, _) = await WritableDriverAsync(); await driver.ShutdownAsync(Ct); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results[0].StatusCode.ShouldBe(BadNotConnected); } // ---- per-slot refusals ---- [Fact] public async Task An_unknown_reference_is_refused_in_its_own_slot() { var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(UnknownRawPath, 1.0)], Ct); results[0].StatusCode.ShouldBe(BadNodeIdUnknown); client.Writes.ShouldBeEmpty(); } [Fact] public async Task A_condition_data_item_is_refused_as_not_writable() { // CONDITION PUTs carry alarm semantics and a different level|text wire format — excluded from // v1. BadNotWritable, not BadTypeMismatch: the value was never the problem. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(ConditionRawPath, "NORMAL")], Ct); results[0].StatusCode.ShouldBe(BadNotWritable); client.Writes.ShouldBeEmpty(); } [Fact] public async Task A_null_value_is_refused_as_a_type_mismatch() { // A write IS a URL query value; there is no null to send. Refusing beats writing the empty // string, which the Agent would accept as a real observation. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, null)], Ct); results[0].StatusCode.ShouldBe(BadTypeMismatch); client.Writes.ShouldBeEmpty(); } [Fact] public async Task An_array_value_is_refused_as_a_type_mismatch() { // Convert.ToString would happily render "System.Double[]" and the Agent would happily store it: // a silent corruption visible only on read-back. Array/TIME_SERIES writes are out of scope. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, new[] { 1.0, 2.0 })], Ct); results[0].StatusCode.ShouldBe(BadTypeMismatch); client.Writes.ShouldBeEmpty(); } [Fact] public async Task A_non_convertible_composite_value_is_refused_as_a_type_mismatch() { // Driver.MTConnect-006. The array guard's stated REASON is "Convert.ToString falls back to // object.ToString for a non-IConvertible value", and an array is only one shape carrying that // property. A List from a non-SDK caller (or an Opc.Ua.ExtensionObject, or an XmlElement) // renders as its TYPE NAME, which the Agent stores under a Good status — the identical // invisible corruption the array guard exists to prevent, refused nowhere. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync( [new WriteRequest(PositionRawPath, new List { 1.0, 2.0 })], Ct); results[0].StatusCode.ShouldBe(BadTypeMismatch); client.Writes.ShouldBeEmpty(); } [Theory] [InlineData((byte)7, "7")] [InlineData('x', "x")] [InlineData("already text", "already text")] public async Task An_IConvertible_scalar_still_writes(object value, string expected) { // Falsification control for the guard above: the accepted set must be UNCHANGED. Every scalar // this driver can carry is IConvertible, so a refusal expressed as "not a primitive I // recognise" — rather than as the IConvertible property the remark relies on — would silently // start refusing real writes instead of composite ones. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, value)], Ct); results[0].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem().Value.ShouldBe(expected); } [Fact] public async Task A_DateTime_still_writes_as_its_invariant_string() { // DateTime is IConvertible but is neither a primitive nor a string — the shape most likely to // be swept up by a hand-rolled "is this a scalar?" guard. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync( [new WriteRequest(PositionRawPath, new DateTime(2026, 8, 10, 13, 45, 0, DateTimeKind.Utc))], Ct); results[0].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem().Value.ShouldBe("08/10/2026 13:45:00"); } [Fact] public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more() { // Convert.ToString runs the value's OWN ToString — arbitrary code that may throw anything — // and it runs OUTSIDE WriteAsync's per-slot try. A narrow catch filter let every exception // other than Format/InvalidCast/Overflow escape WriteAsync and fail the WHOLE batch, breaking // the never-throws contract. The load-bearing half of this test is the second slot: the good // write still happens. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync( [ new WriteRequest(PositionRawPath, new ThrowingFormatter()), new WriteRequest(PositionRawPath, 42.5), ], Ct); results.Count.ShouldBe(2); results[0].StatusCode.ShouldBe(BadTypeMismatch); results[1].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5"); } /// A value whose throws something no conversion filter would name. private sealed class ThrowingFormatter { public override string ToString() => throw new InvalidOperationException("this formatter is broken"); } // ---- what actually reaches the seam ---- [Fact] public async Task An_accepted_write_reports_good_and_names_the_owning_device_and_data_item() { // The device NAME is resolved from the /probe model, not from config: the Agent's PUT surface // is addressed per device, and a multi-device Agent must be addressed correctly with no extra // authoring. The RawPath is translated to the DataItem id, exactly as the read path does. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results[0].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem() .ShouldBe((FixtureDeviceName, PositionDataItemId, "42.5")); } [Fact] public async Task A_value_is_rendered_with_the_invariant_culture() { // A comma decimal separator would be accepted by the Agent and read back as a different number. var (driver, client) = await WritableDriverAsync(); await driver.WriteAsync([new WriteRequest(PositionRawPath, 1234.5678d)], Ct); client.Writes.ShouldHaveSingleItem().Value.ShouldBe("1234.5678"); } [Fact] public async Task A_boolean_is_rendered_as_its_invariant_true_false_string() { // Boolean.ToString is culture-independent, so the invariant rendering is "True"/"False" — NOT // "1"/"0". Documented because it is the value an MTConnect consumer will see verbatim. var (driver, client) = await WritableDriverAsync(); await driver.WriteAsync([new WriteRequest(PositionRawPath, true)], Ct); client.Writes.ShouldHaveSingleItem().Value.ShouldBe("True"); } // ---- device-name resolution ---- [Fact] public async Task A_data_item_the_probe_does_not_declare_falls_back_to_the_configured_device() { // The dataItemId-keyed authoring surface (the driver CLI) and any tag whose DataItem the Agent // has since dropped: config is the only device name available, and it is a real one. var (driver, client) = await InitializedAsync( Opts(writeEnabled: true, deviceName: "ConfiguredDevice", Entry(PositionRawPath, "not_in_probe"))); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct); results[0].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem().ShouldBe(("ConfiguredDevice", "not_in_probe", "7")); } [Fact] public async Task A_data_item_with_no_resolvable_device_is_refused_as_an_unknown_node() { // Neither the probe model nor config names a device, so there is no URL to POST to. Refused // rather than guessed: a wrong device path is a 404 the operator reads as "the Agent is broken". var (driver, client) = await InitializedAsync( Opts(writeEnabled: true, deviceName: null, Entry(PositionRawPath, "not_in_probe"))); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct); results[0].StatusCode.ShouldBe(BadNodeIdUnknown); client.Writes.ShouldBeEmpty(); } // ---- how the Agent's own answers map ---- [Fact] public async Task An_agent_refusal_maps_to_a_communication_error_and_logs_the_agents_own_text() { var logger = new RecordingDriverLogger(); var (driver, client) = await WritableDriverAsync(logger); client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device"); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results[0].StatusCode.ShouldBe(BadCommunicationError); logger.WarningsSnapshot().ShouldContain(w => w.Contains("UNSUPPORTED: no such device", StringComparison.Ordinal)); } [Fact] public async Task An_agent_refusal_never_reaches_the_health_LastError() { // Type-only discipline: LastError carries an exception TYPE, never Agent-authored text, and a // refused write is not evidence the driver's read path is degraded either. var (driver, client) = await WritableDriverAsync(); var before = driver.GetHealth(); client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device"); await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); var after = driver.GetHealth(); after.LastError.ShouldBe(before.LastError); after.State.ShouldBe(before.State); } [Fact] public async Task A_transport_failure_maps_to_a_communication_error_without_throwing() { var (driver, client) = await WritableDriverAsync(); client.WriteFailure = new HttpRequestException("connection refused"); var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct); results[0].StatusCode.ShouldBe(BadCommunicationError); } // ---- batch contract ---- [Fact] public async Task Cancellation_fills_the_remaining_slots_and_returns_the_batch() { // Never throws, even cancelled: the caller gets one status per request or the node-manager // cannot tell which writes were attempted. var (driver, client) = await WritableDriverAsync(); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); var results = await driver.WriteAsync( [new WriteRequest(PositionRawPath, 1.0), new WriteRequest(PositionRawPath, 2.0)], cts.Token); results.Count.ShouldBe(2); results.ShouldAllBe(r => r.StatusCode == BadCommunicationError); client.Writes.ShouldBeEmpty(); } [Fact] public async Task Cancellation_mid_batch_leaves_the_slots_already_written_with_their_real_outcomes() { // The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against // the Agent before the token was cancelled, and reporting it BadCommunicationError would tell // an operator a write failed that actually landed — the one thing worse than reporting a // failure. Cancellation is injected from inside slot 0's own Agent call, so the ordering is a // fact of the run rather than a timing hope. (It used to be injected from a value's ToString; // such a value is not IConvertible and is now refused before it is ever rendered — see // Driver.MTConnect-006 — so the injection point moved to the seam it is asserting around.) var (driver, client) = await WritableDriverAsync(); using var cts = new CancellationTokenSource(); client.AfterWrite = () => cts.Cancel(); var results = await driver.WriteAsync( [ new WriteRequest(PositionRawPath, 42.5), new WriteRequest(PositionRawPath, 7.5), new WriteRequest(PositionRawPath, 9.5), ], cts.Token); results.Count.ShouldBe(3); results[0].StatusCode.ShouldBe(Good); results[1].StatusCode.ShouldBe(BadCommunicationError); results[2].StatusCode.ShouldBe(BadCommunicationError); // Exactly the pre-cancellation slot reached the Agent. client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5"); } [Fact] public async Task One_bad_request_does_not_fail_the_others_in_the_batch() { // Mirrors ReadAsync: failing the whole call would let one stale reference reject every other // write in the same OPC UA request. var (driver, client) = await WritableDriverAsync(); var results = await driver.WriteAsync( [ new WriteRequest(UnknownRawPath, 1.0), new WriteRequest(ConditionRawPath, "NORMAL"), new WriteRequest(PositionRawPath, 42.5), ], Ct); // Order-preserving, one status per request, each naming its own distinct cause. results.Count.ShouldBe(3); results[0].StatusCode.ShouldBe(BadNodeIdUnknown); results[1].StatusCode.ShouldBe(BadNotWritable); results[2].StatusCode.ShouldBe(Good); client.Writes.ShouldHaveSingleItem().DataItemId.ShouldBe(PositionDataItemId); } [Fact] public async Task An_empty_batch_is_answered_with_an_empty_result() => (await NewDriver(Opts()).Driver.WriteAsync([], Ct)).ShouldBeEmpty(); [Fact] public async Task WriteAsync_rejects_a_null_batch() => await Should.ThrowAsync( async () => await NewDriver(Opts()).Driver.WriteAsync(null!, Ct)); }