2d7dffcc42
Driver.MTConnect-005/-006/-007, each TDD'd red-first. - -005: _options was the one field at the StartCoreAsync commit point published by a plain write and read lock-free from ReadAsync/WriteAsync/DiscoverAsync/HostName/health. Now Volatile.Write/Volatile.Read like _session/_binding/_announcedInstanceId, pinned by a source scan (MTConnectOptionsPublicationTests) that fails on any unfenced touch. - -006: TryFormatValue guarded null and Array, but Convert.ToString renders ANY non-IConvertible value as its type name under a Good status. Guard is now the property the remark relies on; the accepted set (string/bool/numeric/DateTime) is unchanged. - -007: MTConnectAgentClient.Dispose was a check-then-set on a non-volatile bool, so two racing disposals both passed and the loser cancelled an already-disposed CTS. Now an Interlocked.Exchange claim over an int, with the request seams reading it volatile.
469 lines
20 KiB
C#
469 lines
20 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="MTConnectDriver"/>'s <see cref="IWritable"/> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The write itself is the live gate's job.</b> Everything below stops at the
|
|
/// <see cref="IMTConnectAgentClient"/> seam and asserts what the driver <i>asked</i> the Agent
|
|
/// for (<see cref="CannedAgentClient.Writes"/>), never that a fake was called for its own sake —
|
|
/// the same philosophy as <c>MqttDriverWriteTests</c>. 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A write is observation injection, not machine command</b> (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 <c>AllowPut</c>, and this driver's
|
|
/// <c>write.enabled</c> — and why the disabled leg below is the first test in the file.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MTConnectWriteTests
|
|
{
|
|
private const string AgentUri = "http://fixture-agent:5000";
|
|
|
|
/// <summary>The device the canned <c>Fixtures/probe.xml</c> declares — <c>id="dev1" name="VMC-3Axis"</c>.</summary>
|
|
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;
|
|
|
|
/// <summary>One authored raw tag bound to a DataItem id, as the browse-commit flow writes it.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The ordinary rig: write on, one writable position tag and the fixture's CONDITION item.</summary>
|
|
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<IWritable>();
|
|
|
|
// ---- 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<T> 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<double> { 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");
|
|
}
|
|
|
|
/// <summary>A value whose <see cref="ToString"/> throws something no conversion filter would name.</summary>
|
|
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<ArgumentNullException>(
|
|
async () => await NewDriver(Opts()).Driver.WriteAsync(null!, Ct));
|
|
}
|