diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml index 19979d1a..8b7238b2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml @@ -25,12 +25,20 @@ EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case) EVENT, LINE_NUMBER -> Int64 fixture_linenumber EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode - EVENT, free text -> String fixture_program, fixture_block (never fed) + EVENT, free text -> String fixture_program, fixture_block (never fed), + fixture_writeback (never fed; write target) EVENT, DATA_SET representation -> String fixture_varset CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping. + + fixture_writeback is likewise never fed, and exists for a different reason: it is the + target the write legs POST to (agent.cfg has AllowPut = true). It is a SEPARATE item from + fixture_block on purpose — the first successful write would take fixture_block off + UNAVAILABLE for the life of the container and quietly rob the mapping test above of its + subject. Because no adapter ever writes fixture_writeback, the value a write leg reads back + out of /current can only have come from that write. --> + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg index bbc22692..7243dc11 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg @@ -22,8 +22,18 @@ BufferSize = 14 CheckpointFrequency = 1000 MaxAssets = 128 -# MTConnect is a read-only source for this driver; the Agent must not accept writes. -AllowPut = false +# Writes ARE in scope for this fixture, because the driver now implements IWritable (see +# docs/plans/2026-08-05-mtconnect-writeback-design.md). The mechanism is this very key: with +# AllowPut on, POST {agent}/{device}?{dataItemId}={value} sets what the Agent REPORTS for that +# observation. It is injection into the Agent's buffer -- visible to every consumer of the +# Agent, overwritten by the next SHDR update for the same DataItem -- and never a command to a +# machine. The integration suite's write legs (round-trip, agent refusal, disabled gate) need +# it on; with the driver's own write.enabled off, the shipped read-only behaviour is unchanged. +# +# No AllowPutFrom is set, so any host that can reach the port may write. Acceptable for a +# throwaway test Agent on the fixture network; NOT acceptable for a plant Agent, where the +# allow-list is the second half of the gate. +AllowPut = true SchemaVersion = 2.0 MonitorConfigFiles = false diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 2e08a622..48c909a1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -55,14 +55,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests; /// PART_COUNT) Assert.Skips rather than passing vacuously. /// /// -/// Two things this suite deliberately does NOT cover, with reasons. -/// (a) MTConnectError under HTTP 200. The driver handles it and canned tests +/// One thing this suite deliberately does NOT cover, with a reason. +/// MTConnectError under HTTP 200. The driver handles it and canned tests /// pin it, but a modern Agent cannot be made to produce it: mtconnect/agent 2.7 /// answers an unknown device with 404 and an out-of-range sequence with 400, /// each carrying a well-formed MTConnectError body (verified live). The under-200 /// shape belongs to older/third-party Agents and stays canned-only. -/// (b) Writes. MTConnect is a read-only source protocol and the driver implements no -/// IWritable; the fixture Agent runs AllowPut = false to match. +/// +/// +/// Writes ARE covered now — the driver gained IWritable and the fixture Agent +/// runs AllowPut = true to match (Docker/agent.cfg). Three legs: the round trip +/// (a driver write the Agent then reports back), the Agent's own refusal of an undeclared +/// DataItem mapping to BadCommunicationError, and the write.enabled gate +/// refusing with BadNotWritable without touching the Agent. The injected target is +/// fixture_writeback, a DataItem the fixture's adapter never feeds, so an observed +/// value can only have come from the write under test. A write is observation +/// injection, not machine command: it sets what the Agent reports, which is exactly why +/// a live Agent is the only thing that can prove it. /// /// [Collection(MTConnectAgentCollection.Name)] @@ -83,9 +92,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) private const uint BadNoCommunication = 0x80310000u; + /// The Agent answered and refused the write (non-2xx, or an MTConnectError under 200). + private const uint BadCommunicationError = 0x80050000u; + + /// No DataItem this driver serves answers to the reference — decided locally, before the wire. + private const uint BadNodeIdUnknown = 0x80340000u; + + /// write.enabled is off (or the DataItem is CONDITION-category): nothing is attempted. + private const uint BadNotWritable = 0x803B0000u; + + /// + /// Overrides the DataItem the write legs inject into, for a run pointed at a real Agent whose + /// model has no fixture_writeback. + /// + private const string WriteTargetEnvVar = "MTCONNECT_WRITE_TARGET"; + + /// + /// The fixture's dedicated write target: a free-text EVENT the SHDR adapter NEVER feeds, so a + /// value observed on it can only have come from the write under test. Deliberately NOT + /// fixture_block — the first successful write would take that item off + /// UNAVAILABLE for the life of the container and rob + /// of its subject. + /// + private const string DefaultWriteTargetId = "fixture_writeback"; + /// How long a subscription test waits for the live /sample stream to move a value. private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45); + /// + /// How long a write leg waits for the Agent to report the injected observation on + /// /current. A PUT lands in the Agent's buffer synchronously, so this is slack for the + /// HTTP round trips rather than for any processing the Agent has to schedule. + /// + private static readonly TimeSpan WriteVisibleWait = TimeSpan.FromSeconds(15); + private static CancellationToken Ct => TestContext.Current.CancellationToken; // ---- reachability ---- @@ -537,6 +577,169 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) } } + // ---- writes: the Agent's AllowPut surface ---- + + /// + /// The write round trip, and the only thing that can prove the PUT surface at all. A + /// driver write must be accepted by the Agent and must then be what the Agent + /// reports for that DataItem — read back off /current over plain HTTP (not + /// through the parser under test), and then through the driver's own read path. + /// + /// + /// + /// The value is unique per run (a suffix). + /// The Agent keeps an injected observation for the life of the container, so a constant + /// would be satisfied by a previous run's leftover and the test would pass against a + /// driver whose write never left the process. + /// + /// + /// The target is a DataItem the SHDR adapter never feeds, so no observation but + /// this write can put a value on it. Ring-buffer eviction is not a hazard here even + /// though the fixture's stream is busy: BufferSize = 14 in agent.cfg is an + /// exponent (2^14 = 16384 observations), and /current is answered from the + /// Agent's checkpoint — the newest observation per DataItem — which survives the ring + /// wrapping past it. + /// + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Write_injects_an_observation_the_agent_then_reports() + { + SkipIfDown(); + + var target = ResolveWriteTarget(); + if (target is null) + { + Assert.Skip(NoWriteTargetReason()); + } + + var (dataItemId, deviceName) = target!.Value; + var value = $"OTOPCUA-WRITE-{Environment.TickCount64:X}"; + + await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)], writeEnabled: true); + + var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct); + + results.Count.ShouldBe(1); + results[0].StatusCode.ShouldBe( + Good, + $"the Agent at {agent.AgentUri} must accept a write to DataItem '{dataItemId}' on device " + + $"'{deviceName}'. A refusal here is most often AllowPut still false in the Agent's config."); + + var observed = await WaitForObservationAsync(dataItemId, value, WriteVisibleWait); + observed.ShouldBe( + value, + $"the Agent must REPORT the injected value on /current within {WriteVisibleWait.TotalSeconds:F0}s; " + + "a Good write whose value never appears is a write that went nowhere"); + + var reads = await live.Driver.ReadAsync([dataItemId], Ct); + reads.Count.ShouldBe(1); + reads[0].StatusCode.ShouldBe(Good, "the injected observation must read back through the driver's own path"); + reads[0].Value.ShouldBe(value); + } + + /// + /// The Agent's own refusal, mapped to BadCommunicationError. A write naming a + /// DataItem the Agent does not declare reaches the wire and comes back refused. + /// + /// + /// + /// Why this reaches the Agent at all, when an unknown reference is normally answered + /// locally. BuildWriteTargets derives a write target for every DataItem the + /// binding serves — i.e. every authored tag — resolving the device from the probe + /// model and falling back to the configured deviceName. So an authored tag naming + /// an id the Agent never declared, on a driver whose deviceName IS configured, + /// resolves to a real URL and is POSTed. That is precisely the shape a stale tag has after + /// a machine's DataItem is renamed, and the Agent's answer to it is the refusal this leg + /// pins. + /// + /// + /// The second half is the falsifiability control, and without it the leg proves + /// nothing: the SAME unknown id on a driver with NO configured device name has no URL to + /// POST to and must be refused locally as BadNodeIdUnknown. Two different codes for + /// the same id is what shows the first write actually went to the Agent rather than being + /// short-circuited into a look-alike failure. + /// + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Write_to_a_data_item_the_agent_does_not_declare_reports_the_agents_refusal() + { + SkipIfDown(); + + var target = ResolveWriteTarget(); + if (target is null) + { + Assert.Skip(NoWriteTargetReason()); + } + + var deviceName = target!.Value.DeviceName; + var unknownId = $"otopcua_no_such_data_item_{Environment.TickCount64:X}"; + + await using (var live = await LiveDriverAsync( + [Tag(unknownId, DriverDataType.String)], writeEnabled: true, deviceName: deviceName)) + { + var refused = await live.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct); + + refused.Count.ShouldBe(1); + refused[0].StatusCode.ShouldBe( + BadCommunicationError, + $"the Agent at {agent.AgentUri} must refuse a write to DataItem '{unknownId}', which device " + + $"'{deviceName}' does not declare, and the driver must report that refusal as an Agent " + + "failure rather than as success"); + } + + await using var noDevice = await LiveDriverAsync([Tag(unknownId, DriverDataType.String)], writeEnabled: true); + + var local = await noDevice.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct); + + local.Count.ShouldBe(1); + local[0].StatusCode.ShouldBe( + BadNodeIdUnknown, + "with no device resolvable there is no URL to POST to, so the refusal must be decided locally — " + + "and a run where BOTH writes report the same code proves nothing about the Agent"); + } + + /// + /// The driver-side gate, which is the half an operator controls. With + /// write.enabled absent the same write is refused BadNotWritable — and the + /// Agent's /current is untouched, which is the assertion that distinguishes "refused" + /// from "reported as refused and sent anyway". + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Write_is_refused_without_touching_the_agent_when_write_is_not_enabled() + { + SkipIfDown(); + + var target = ResolveWriteTarget(); + if (target is null) + { + Assert.Skip(NoWriteTargetReason()); + } + + var dataItemId = target!.Value.DataItemId; + var value = $"OTOPCUA-DISABLED-{Environment.TickCount64:X}"; + var before = await ObservationValueAsync(dataItemId); + + // No writeEnabled: the driver's own gate, with the Agent's AllowPut left on. + await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)]); + + var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct); + + results.Count.ShouldBe(1); + results[0].StatusCode.ShouldBe( + BadNotWritable, + "write.enabled is absent, so the honest answer is 'this is not writable' — not a comms failure"); + + // Long enough that a write which HAD been dispatched would be visible: the round-trip leg + // observes one inside WriteVisibleWait, and this waits for the same value to appear. + var after = await WaitForObservationAsync(dataItemId, value, TimeSpan.FromSeconds(3)); + + after.ShouldNotBe(value, "a gated write must never reach the Agent's observation buffer"); + after.ShouldBe( + before, + $"nothing else feeds DataItem '{dataItemId}', so its reported value must be exactly what it was " + + "before the gated write"); + } + // ---- helpers ---- private void SkipIfDown() @@ -569,13 +772,17 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) return port; } - private MTConnectDriverOptions LiveOptions(string? deviceName = null) => + private MTConnectDriverOptions LiveOptions(string? deviceName = null, bool writeEnabled = false) => new() { AgentUri = agent.AgentUri, DeviceName = deviceName, RequestTimeoutMs = 15_000, + // Off unless a write leg asks for it: every other test in this suite must run against the + // shipped read-only driver, whatever the fixture Agent's AllowPut says. + Write = new MTConnectWriteOptions { Enabled = writeEnabled }, + // Faster than the 1000 ms default so a subscription sees several distinct chunks inside // StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on. SampleIntervalMs = 200, @@ -597,6 +804,10 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) sampleCount = options.SampleCount, heartbeatMs = options.HeartbeatMs, probe = new { enabled = false }, + + // Sourced from the options rather than a separate parameter, so the config the driver + // parses can never disagree with the options object a test thinks it built. + write = new { enabled = options.Write.Enabled }, tags = (tags ?? []).Select(t => new { fullName = t.FullName, @@ -607,9 +818,16 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type); /// Initializes a driver against the live Agent with the supplied authored tags. - private async Task LiveDriverAsync(IEnumerable tags) + /// The authored tags, by DataItem id. + /// The driver's own write.enabled gate; off by default. + /// + /// The configured device scope, and the write path's device fallback for a DataItem the probe + /// model does not declare. Null (the default) leaves the driver unscoped. + /// + private async Task LiveDriverAsync( + IEnumerable tags, bool writeEnabled = false, string? deviceName = null) { - var options = LiveOptions(); + var options = LiveOptions(deviceName, writeEnabled); var driver = new MTConnectDriver(options, "mtconnect-live"); await driver.InitializeAsync(ConfigJson(options, tags), Ct); @@ -650,6 +868,112 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) return XDocument.Parse(body); } + /// + /// The DataItem the write legs inject into, and the device that owns it — or null when + /// this Agent declares nothing suitable, which is a skip rather than a failure. + /// + /// + /// Resolved from the Agent's own /probe like every other expectation in this suite, and + /// overridable via so a run pointed at a real machine tool can + /// name a DataItem its operator is willing to have injected. CONDITION-category items and the + /// TIME_SERIES / DATA_SET / TABLE representations are excluded because WriteAsync + /// itself refuses them — pointing a write leg at one would assert the wrong refusal. + /// + private (string DataItemId, string DeviceName)? ResolveWriteTarget() + { + var id = (Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId).Trim(); + + var declared = agent.DeclaredDataItems + .FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal)); + + if (declared is null || + Is(declared.Category, "CONDITION") || + Is(declared.Representation, "TIME_SERIES") || + Is(declared.Representation, "DATA_SET") || + Is(declared.Representation, "TABLE")) + { + return null; + } + + var device = OwningDeviceName(id); + + return device is null ? null : (id, device); + } + + /// The skip message for a run whose Agent declares no usable write target. + private string NoWriteTargetReason() => + $"The Agent at {agent.AgentUri} declares no writable DataItem " + + $"'{Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId}' " + + $"(a non-CONDITION, scalar item under a Device). Set {WriteTargetEnvVar} to a DataItem this " + + "Agent declares and is safe to inject an observation into, or run against the fixture's own " + + "Devices.xml, which carries a never-fed one for exactly this purpose."; + + /// + /// The device a DataItem is declared under, addressed the way the Agent's PUT URL addresses + /// it: by name, falling back to id for a nameless device. + /// + private string? OwningDeviceName(string dataItemId) + { + var element = agent.ProbeDocument? + .Descendants() + .FirstOrDefault(e => + e.Name.LocalName == "DataItem" && + string.Equals((string?)e.Attribute("id"), dataItemId, StringComparison.Ordinal)); + + var device = element?.Ancestors().FirstOrDefault(a => a.Name.LocalName == "Device"); + var name = (string?)device?.Attribute("name") ?? (string?)device?.Attribute("id"); + + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + /// + /// What the Agent currently reports for one DataItem, straight off /current — the raw + /// observation text (UNAVAILABLE included), or null when the snapshot carries no + /// observation for it at all. + /// + private async Task ObservationValueAsync(string dataItemId) => + (await CurrentAsync()) + .Descendants() + .Where(e => !e.HasElements) + .Where(e => string.Equals((string?)e.Attribute("dataItemId"), dataItemId, StringComparison.Ordinal)) + .Select(e => e.Value.Trim()) + .LastOrDefault(); + + /// + /// Polls /current until the DataItem reports or the wait + /// runs out, returning whatever it last observed either way. + /// + /// + /// Returning the last observation rather than a bool is deliberate: a failing assertion can + /// then print what the Agent WAS reporting, which is the difference between "the write never + /// landed" and "the write landed as something else" (a formatting defect). + /// + private async Task WaitForObservationAsync(string dataItemId, string expected, TimeSpan timeout) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + deadline.CancelAfter(timeout); + + string? observed; + do + { + observed = await ObservationValueAsync(dataItemId); + if (string.Equals(observed, expected, StringComparison.Ordinal)) + { + return observed; + } + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(500), deadline.Token); + } + catch (OperationCanceledException) when (!Ct.IsCancellationRequested) + { + return observed; + } + } + while (true); + } + /// /// The DataItem ids the Agent is currently reporting a parseable number for: the live subset /// a read or subscription can make a non-vacuous statement about.