merge: MTConnect review findings resolved (Driver.MTConnect-004 write-back authoring, -005/-006/-007)
# Conflicts: # code-reviews/Driver.MTConnect/findings.md
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-08-10 |
|
| Review date | 2026-08-10 |
|
||||||
| Commit reviewed | `b990adae` |
|
| Commit reviewed | `b990adae` |
|
||||||
| Status | Complete |
|
| Status | Complete |
|
||||||
| Open findings | 4 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ defect left inside these two modules are corrected under `Driver.MTConnect-008`
|
|||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor:1` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:146` |
|
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor:1` · `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs:146` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `IWritable` shipped for this driver on 2026-08-05 (`b29623cf`) behind
|
**Description:** `IWritable` shipped for this driver on 2026-08-05 (`b29623cf`) behind
|
||||||
`Write.Enabled`, and `docs/drivers/MTConnect.md` §"Two independent gates" tells the operator to set
|
`Write.Enabled`, and `docs/drivers/MTConnect.md` §"Two independent gates" tells the operator to set
|
||||||
@@ -192,8 +192,32 @@ edit through the form. The capability is unreachable rather than fragile.
|
|||||||
comment. Until then the driver-side gate is correct and fails closed, which is why this is Medium
|
comment. Until then the driver-side gate is correct and fails closed, which is why this is Medium
|
||||||
rather than High.
|
rather than High.
|
||||||
|
|
||||||
**Resolution:** _(open — the fix belongs in `ZB.MOM.WW.OtOpcUa.AdminUI`, outside this review's
|
**Resolution (2026-08-10):** Fixed `298ad04f`. `MTConnectDriverForm` gained a **Write-back**
|
||||||
modules.)_
|
section — a switch plus form text stating that a write is observation injection, that the Agent's own
|
||||||
|
`AllowPut` is the independent second gate, and that arming it does not by itself make any node
|
||||||
|
writable over OPC UA (`Tag.AccessLevel` still composes `CurrentWrite`; CONDITION items never are).
|
||||||
|
`Write` was added to the form's mirrored `MTConnectConfigDto` and to `MTConnectFormModel`, the false
|
||||||
|
"MTConnect is read-only" header comment is gone, and `docs/drivers/MTConnect.md` §"Two independent
|
||||||
|
gates" now names the section instead of implying a config-DB edit.
|
||||||
|
|
||||||
|
⚠️ **The block is written in BOTH states, unlike the timing knobs.** Those are omitted when unusable
|
||||||
|
so `ParseOptions` substitutes the driver's own default; an omitted `write` block would instead leave a
|
||||||
|
previously-authored `"enabled": true` standing — the switch would appear to turn writing off while the
|
||||||
|
deployed driver went on accepting writes. Pinned by
|
||||||
|
`Turning_write_back_off_clears_a_previously_authored_true`, the falsification control for that
|
||||||
|
decision. The finding's own mitigation (unknown top-level keys survive an edit) is likewise not
|
||||||
|
sufficient on its own: `FromJson` had to LEARN the block, or the modal would show the switch off over
|
||||||
|
a config that has writing on — `Write_back_round_trips_and_a_hand_authored_block_loads_into_the_form`.
|
||||||
|
|
||||||
|
This project has no bUnit, so the seam is unit-tested from both ends rather than through the page:
|
||||||
|
`MTConnectDriverFormModelTests` asserts the exact bytes `BuildConfigJson` emits, and
|
||||||
|
`MTConnectFactoryTests.The_document_the_AdminUI_form_emits_arms_write_back` parses that same document
|
||||||
|
through `MTConnectDriver.ParseOptions` and asserts `MTConnectDriverOptions.Write.Enabled` is true
|
||||||
|
(then false for the switched-off variant). The literal is duplicated because a driver test project
|
||||||
|
cannot reference the AdminUI; `The_write_enabled_document_is_the_literal_the_driver_test_parses` pins
|
||||||
|
the two copies together so a rename fails on the authoring side, naming the literal to update.
|
||||||
|
**Residual: live page verification is still a rig step** — the razor markup itself (the switch's
|
||||||
|
binding and `EmitAsync` wiring) is not covered by any test here.
|
||||||
|
|
||||||
### Driver.MTConnect-005
|
### Driver.MTConnect-005
|
||||||
|
|
||||||
@@ -202,7 +226,7 @@ modules.)_
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:2655` · `:689` · `:797` · `:1425` |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:2655` · `:689` · `:797` · `:1425` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `_options` is the one piece of shared driver state published by a **plain** field
|
**Description:** `_options` is the one piece of shared driver state published by a **plain** field
|
||||||
write (`_options = options;` at the `StartCoreAsync` commit point) and read lock-free from other
|
write (`_options = options;` at the `StartCoreAsync` commit point) and read lock-free from other
|
||||||
@@ -223,7 +247,17 @@ three siblings, or fold it into the `AgentSession` / `TagBinding` snapshot so th
|
|||||||
consistent unit" rule this file already argues for covers it too. Record-only for now: the change
|
consistent unit" rule this file already argues for covers it too. Record-only for now: the change
|
||||||
touches ~10 read sites and the exposure does not justify unreviewed churn.
|
touches ~10 read sites and the exposure does not justify unreviewed churn.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution (2026-08-10):** Fixed `2d7dffcc`. Both writes are `Volatile.Write` (the constructor's as
|
||||||
|
well as the commit point's) and all thirteen reads are `Volatile.Read`; the field gained the remark
|
||||||
|
its three siblings already carried. Two sites took a local snapshot rather than repeating the read —
|
||||||
|
the `_streamUnsupported` refusal, which named the Agent twice, and `GetOrFetchProbeModelAsync`.
|
||||||
|
|
||||||
|
**A source scan is the pin, deliberately.** The defect is a missing fence, and no test can force the
|
||||||
|
interleaving that exposes it on x86/arm64 in bounded time; what actually regresses is a NEW read site
|
||||||
|
written without the fence. `MTConnectOptionsPublicationTests` therefore fails on any unguarded touch
|
||||||
|
of `_options` in `MTConnectDriver.cs` — the same shape as `LibplctagGatewayConsumptionGuardTests`, and
|
||||||
|
red before the change with all 15 sites listed by line. It carries a floor count so the scan cannot go
|
||||||
|
blind if the field is renamed.
|
||||||
|
|
||||||
### Driver.MTConnect-006
|
### Driver.MTConnect-006
|
||||||
|
|
||||||
@@ -232,7 +266,7 @@ touches ~10 read sites and the exposure does not justify unreviewed churn.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:914` |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs:914` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `TryFormatValue` refuses `null` and `Array`, then renders everything else with
|
**Description:** `TryFormatValue` refuses `null` and `Array`, then renders everything else with
|
||||||
`Convert.ToString(value, InvariantCulture)`. Its own remark gives the right reason for the array
|
`Convert.ToString(value, InvariantCulture)`. Its own remark gives the right reason for the array
|
||||||
@@ -250,7 +284,20 @@ MTConnect tag is a scalar Boolean/numeric/String — hence Low.
|
|||||||
that happens to have it: `if (value is null or Array or not IConvertible) return false;`. `string`,
|
that happens to have it: `if (value is null or Array or not IConvertible) return false;`. `string`,
|
||||||
`bool`, every numeric and `DateTime` are `IConvertible`, so the accepted set is unchanged.
|
`bool`, every numeric and `DateTime` are `IConvertible`, so the accepted set is unchanged.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution (2026-08-10):** Fixed `2d7dffcc` exactly as recommended (`Array` kept in the pattern as
|
||||||
|
documentation of the shape this was first noticed on, though it is subsumed). Red first:
|
||||||
|
`A_non_convertible_composite_value_is_refused_as_a_type_mismatch` wrote a `List<double>` and got
|
||||||
|
`Good` back, having injected the list's own type name into the Agent as the observation value.
|
||||||
|
The falsification control is the load-bearing half — `An_IConvertible_scalar_still_writes` plus
|
||||||
|
`A_DateTime_still_writes_as_its_invariant_string` fail any over-correction expressed as "is this a
|
||||||
|
primitive I recognise", which would start refusing real writes.
|
||||||
|
|
||||||
|
⚠️ **Worth knowing: the fix stranded the vehicle of an existing test.**
|
||||||
|
`Cancellation_mid_batch_leaves_the_slots_already_written_with_their_real_outcomes` injected its
|
||||||
|
cancellation from a value's own `ToString`, and such a value is not `IConvertible` — so it was refused
|
||||||
|
before it was ever rendered and the cancellation never happened. The injection point moved to the
|
||||||
|
Agent call (`CannedAgentClient.AfterWrite`), which is the seam that test is asserting around anyway;
|
||||||
|
its determinism property is unchanged.
|
||||||
|
|
||||||
### Driver.MTConnect-007
|
### Driver.MTConnect-007
|
||||||
|
|
||||||
@@ -259,7 +306,7 @@ that happens to have it: `if (value is null or Array or not IConvertible) return
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs:85` |
|
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs:85` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `MTConnectAgentClient.Dispose()` is a plain check-then-set on a non-volatile
|
**Description:** `MTConnectAgentClient.Dispose()` is a plain check-then-set on a non-volatile
|
||||||
`_disposed` field, so two concurrent disposals can both pass the guard. The second then calls
|
`_disposed` field, so two concurrent disposals can both pass the guard. The second then calls
|
||||||
@@ -272,7 +319,16 @@ Low rather than Medium.
|
|||||||
**Recommendation:** Guard with `Interlocked.Exchange(ref _disposed, 1) != 0` over an `int` field, or
|
**Recommendation:** Guard with `Interlocked.Exchange(ref _disposed, 1) != 0` over an `int` field, or
|
||||||
state on `Dispose` that it is not thread-safe and is called only under the driver's lifecycle lock.
|
state on `Dispose` that it is not thread-safe and is called only under the driver's lifecycle lock.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution (2026-08-10):** Fixed `2d7dffcc` — `_disposed` is an `int` claimed with
|
||||||
|
`Interlocked.Exchange`, the four request seams read it with `Volatile.Read`, and `Dispose` now states
|
||||||
|
that it is idempotent under a race. Pinned by `Concurrent_disposals_are_idempotent`.
|
||||||
|
|
||||||
|
**Its budget is part of the fix.** The window is a handful of instructions and the loser only throws
|
||||||
|
if it reaches `Cancel()` after the winner has finished disposing two `HttpClient`s, so a small
|
||||||
|
race proves nothing: four racers over 200 rounds passed against the DEFECTIVE code. Eight racers on a
|
||||||
|
barrier over 3000 rounds failed at round 690, which is the red this resolution rests on. Once the
|
||||||
|
claim is atomic no schedule can fail it, so the test cannot flake in either direction — but do not
|
||||||
|
shrink the budget.
|
||||||
|
|
||||||
### Driver.MTConnect-008
|
### Driver.MTConnect-008
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,10 @@ for why Interfaces was rejected as the write-back shape. Design record:
|
|||||||
`AllowPutFrom = <host list>` so only named hosts may write; that list is the Agent's
|
`AllowPutFrom = <host list>` so only named hosts may write; that list is the Agent's
|
||||||
own access control and this driver neither reads nor influences it.
|
own access control and this driver neither reads nor influences it.
|
||||||
2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default
|
2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default
|
||||||
`false`; an absent block is disabled).
|
`false`; an absent block is disabled). Authored in the AdminUI on the `/raw` driver
|
||||||
|
config modal's **Write-back** section (`MTConnectDriverForm`), which writes the block
|
||||||
|
explicitly in both states — so turning the switch back off clears a previously
|
||||||
|
authored `true` rather than leaving it standing.
|
||||||
|
|
||||||
Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled`
|
Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled`
|
||||||
off every slot is refused `BadNotWritable` without an Agent being touched, and with
|
off every slot is refused `BadNotWritable` without an Agent being touched, and with
|
||||||
|
|||||||
@@ -82,7 +82,16 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
private readonly string _sampleQuerySuffix;
|
private readonly string _sampleQuerySuffix;
|
||||||
private readonly TimeSpan _requestTimeout;
|
private readonly TimeSpan _requestTimeout;
|
||||||
private readonly TimeSpan _streamIdleTimeout;
|
private readonly TimeSpan _streamIdleTimeout;
|
||||||
private bool _disposed;
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>1</c> once <see cref="Dispose"/> has claimed the teardown. An <see cref="int"/> rather
|
||||||
|
/// than a <c>bool</c> so the claim is an <see cref="Interlocked.Exchange(ref int, int)"/>:
|
||||||
|
/// a check-then-set let two concurrent disposals both pass, and the loser then cancelled a
|
||||||
|
/// <see cref="CancellationTokenSource"/> the winner had already disposed
|
||||||
|
/// (Driver.MTConnect-007). Read with <c>Volatile.Read</c> at the request seams for the same
|
||||||
|
/// reason.
|
||||||
|
/// </summary>
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
||||||
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
||||||
@@ -157,7 +166,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
|
|
||||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
||||||
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||||
@@ -171,7 +180,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
|
|
||||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -185,7 +194,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
long from,
|
long from,
|
||||||
[EnumeratorCancellation] CancellationToken ct)
|
[EnumeratorCancellation] CancellationToken ct)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
|
|
||||||
var uri = BuildSampleUri(from);
|
var uri = BuildSampleUri(from);
|
||||||
|
|
||||||
@@ -257,7 +266,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -371,15 +380,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent, including under a race: the teardown is claimed with a single atomic exchange,
|
||||||
|
/// so a second concurrent caller returns rather than cancelling an already-disposed
|
||||||
|
/// <see cref="CancellationTokenSource"/> (Driver.MTConnect-007).
|
||||||
|
/// </remarks>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
|
||||||
|
|
||||||
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
|
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
|
||||||
// than a disposed handler.
|
// than a disposed handler.
|
||||||
_disposeCts.Cancel();
|
_disposeCts.Cancel();
|
||||||
|
|||||||
@@ -292,6 +292,21 @@ public sealed class MTConnectDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private long _announcedInstanceId;
|
private long _announcedInstanceId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The options document currently in force. Installed at the <c>StartCoreAsync</c> commit
|
||||||
|
/// point alongside <see cref="_binding"/> / <see cref="_session"/> /
|
||||||
|
/// <see cref="_announcedInstanceId"/>, and read lock-free from <c>ReadAsync</c>,
|
||||||
|
/// <c>WriteAsync</c>, <c>DiscoverAsync</c>, <c>HostName</c> and the health paths.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Published with <c>Volatile.Write</c> and read with <c>Volatile.Read</c>, exactly
|
||||||
|
/// like its siblings</b>, so a reader that
|
||||||
|
/// takes this field BEFORE it takes <see cref="_binding"/> — which both <c>ReadAsync</c>
|
||||||
|
/// and <c>WriteAsync</c> do — has the ordering that stops it judging a batch against the
|
||||||
|
/// previous document's <c>Write.Enabled</c> / <c>RequestTimeoutMs</c> paired with the new
|
||||||
|
/// tag table (Driver.MTConnect-005). Pinned by
|
||||||
|
/// <c>MTConnectOptionsPublicationTests</c>, which fails on any unfenced touch.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
private MTConnectDriverOptions _options;
|
private MTConnectDriverOptions _options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -418,7 +433,7 @@ public sealed class MTConnectDriver
|
|||||||
// Sets _options AND the tag tables derived from it. No /probe model is available here (the
|
// Sets _options AND the tag tables derived from it. No /probe model is available here (the
|
||||||
// constructor dials nothing), so a raw tag whose blob declares no type falls back to String
|
// constructor dials nothing), so a raw tag whose blob declares no type falls back to String
|
||||||
// until InitializeAsync rebuilds the binding against the Agent's own declaration.
|
// until InitializeAsync rebuilds the binding against the Agent's own declaration.
|
||||||
_options = options;
|
Volatile.Write(ref _options, options);
|
||||||
_binding = BuildBinding(options, probeModel: null);
|
_binding = BuildBinding(options, probeModel: null);
|
||||||
|
|
||||||
// Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
|
// Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
|
||||||
@@ -506,7 +521,7 @@ public sealed class MTConnectDriver
|
|||||||
internal Task? ProbeLoopTask => Volatile.Read(ref _probeTask);
|
internal Task? ProbeLoopTask => Volatile.Read(ref _probeTask);
|
||||||
|
|
||||||
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
|
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
|
||||||
internal MTConnectDriverOptions EffectiveOptions => _options;
|
internal MTConnectDriverOptions EffectiveOptions => Volatile.Read(ref _options);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -581,7 +596,7 @@ public sealed class MTConnectDriver
|
|||||||
await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
|
await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
|
await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (existing is null || RequiresClientRebuild(_options, incoming))
|
if (existing is null || RequiresClientRebuild(Volatile.Read(ref _options), incoming))
|
||||||
{
|
{
|
||||||
await TeardownCoreAsync().ConfigureAwait(false);
|
await TeardownCoreAsync().ConfigureAwait(false);
|
||||||
await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false);
|
await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -686,7 +701,7 @@ public sealed class MTConnectDriver
|
|||||||
// One read of the session reference: the client used below is the one that was live at this
|
// One read of the session reference: the client used below is the one that was live at this
|
||||||
// instant, never a half-swapped pairing (see the _session remarks).
|
// instant, never a half-swapped pairing (see the _session remarks).
|
||||||
var client = Volatile.Read(ref _session)?.Client;
|
var client = Volatile.Read(ref _session)?.Client;
|
||||||
var options = _options;
|
var options = Volatile.Read(ref _options);
|
||||||
|
|
||||||
// Read once, for the same reason the session is: every reference in this batch must be
|
// Read once, for the same reason the session is: every reference in this batch must be
|
||||||
// resolved against ONE tag table, not half against the table a concurrent re-initialize
|
// resolved against ONE tag table, not half against the table a concurrent re-initialize
|
||||||
@@ -794,7 +809,7 @@ public sealed class MTConnectDriver
|
|||||||
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
|
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
|
||||||
// against ONE options document and ONE tag table, never half against a table a concurrent
|
// against ONE options document and ONE tag table, never half against a table a concurrent
|
||||||
// re-initialize just replaced.
|
// re-initialize just replaced.
|
||||||
var options = _options;
|
var options = Volatile.Read(ref _options);
|
||||||
var client = Volatile.Read(ref _session)?.Client;
|
var client = Volatile.Read(ref _session)?.Client;
|
||||||
var binding = Volatile.Read(ref _binding);
|
var binding = Volatile.Read(ref _binding);
|
||||||
|
|
||||||
@@ -895,12 +910,18 @@ public sealed class MTConnectDriver
|
|||||||
/// it is what an MTConnect consumer will see verbatim.
|
/// it is what an MTConnect consumer will see verbatim.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Arrays are refused rather than rendered.</b>
|
/// <b>Anything that is not <see cref="IConvertible"/> is refused rather than rendered.</b>
|
||||||
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
|
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
|
||||||
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
|
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
|
||||||
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
|
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
|
||||||
/// happily, making the corruption invisible until someone reads the observation back.
|
/// happily, making the corruption invisible until someone reads the observation back.
|
||||||
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
|
/// The guard is therefore written against that property itself rather than against
|
||||||
|
/// <see cref="Array"/>, the one type it was first noticed on: an
|
||||||
|
/// <c>Opc.Ua.ExtensionObject</c>, an <c>XmlElement</c> or a <c>List<T></c> from a
|
||||||
|
/// non-SDK caller corrupts an observation in exactly the same way (Driver.MTConnect-006).
|
||||||
|
/// <c>string</c>, <c>bool</c>, <see cref="DateTime"/> and every numeric type ARE
|
||||||
|
/// <see cref="IConvertible"/>, so the accepted set is unchanged. TIME_SERIES / DATA_SET
|
||||||
|
/// writes are out of scope for v1.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
|
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
|
||||||
@@ -916,8 +937,10 @@ public sealed class MTConnectDriver
|
|||||||
text = string.Empty;
|
text = string.Empty;
|
||||||
|
|
||||||
// A PUT query has no null to send, and the empty string is a real observation rather than an
|
// A PUT query has no null to send, and the empty string is a real observation rather than an
|
||||||
// absence — so "no value" is refused instead of being silently turned into one.
|
// absence — so "no value" is refused instead of being silently turned into one. Everything
|
||||||
if (value is null or Array)
|
// that is not IConvertible is refused with it: Convert.ToString renders such a value as its
|
||||||
|
// TYPE NAME, which the Agent stores under a Good status (see the remarks).
|
||||||
|
if (value is null or Array or not IConvertible)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1202,7 +1225,7 @@ public sealed class MTConnectDriver
|
|||||||
// to another.
|
// to another.
|
||||||
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
|
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
|
||||||
// device and half to another, nor stamp half its leaves writable and half not.
|
// device and half to another, nor stamp half its leaves writable and half not.
|
||||||
var options = _options;
|
var options = Volatile.Read(ref _options);
|
||||||
var deviceScope = options.DeviceName?.Trim();
|
var deviceScope = options.DeviceName?.Trim();
|
||||||
var writeEnabled = options.Write.Enabled;
|
var writeEnabled = options.Write.Enabled;
|
||||||
|
|
||||||
@@ -1422,7 +1445,7 @@ public sealed class MTConnectDriver
|
|||||||
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
|
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
|
||||||
/// Admin dashboard by endpoint rather than by driver-instance id.
|
/// Admin dashboard by endpoint rather than by driver-instance id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string HostName => _options.AgentUri;
|
public string HostName => Volatile.Read(ref _options).AgentUri;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1796,12 +1819,14 @@ public sealed class MTConnectDriver
|
|||||||
// that is not running; without this, the resubscribe returns a handle, the caller is
|
// that is not running; without this, the resubscribe returns a handle, the caller is
|
||||||
// told SubscriptionEstablished, and the next successful read reports a perfectly green
|
// told SubscriptionEstablished, and the next successful read reports a perfectly green
|
||||||
// driver over a subscription that can never deliver a value (#485).
|
// driver over a subscription that can never deliver a value (#485).
|
||||||
|
var agentUri = Volatile.Read(ref _options).AgentUri;
|
||||||
|
|
||||||
_streamDegraded = true;
|
_streamDegraded = true;
|
||||||
Degrade($"The MTConnect Agent at '{_options.AgentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
|
Degrade($"The MTConnect Agent at '{agentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
|
||||||
|
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"MTConnect driver {DriverInstanceId} refused to start a /sample stream for {AgentUri}: the endpoint was already found not to stream. The subscription is registered but will receive NO updates until the driver is re-initialized.",
|
"MTConnect driver {DriverInstanceId} refused to start a /sample stream for {AgentUri}: the endpoint was already found not to stream. The subscription is registered but will receive NO updates until the driver is re-initialized.",
|
||||||
_driverInstanceId, _options.AgentUri);
|
_driverInstanceId, agentUri);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1828,7 +1853,7 @@ public sealed class MTConnectDriver
|
|||||||
// _subscriberFaultLogged).
|
// _subscriberFaultLogged).
|
||||||
Interlocked.Exchange(ref _subscriberFaultLogged, 0);
|
Interlocked.Exchange(ref _subscriberFaultLogged, 0);
|
||||||
|
|
||||||
var options = _options;
|
var options = Volatile.Read(ref _options);
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
_pumpCts = cts;
|
_pumpCts = cts;
|
||||||
_pumpTask = Task.Run(
|
_pumpTask = Task.Run(
|
||||||
@@ -2497,7 +2522,8 @@ public sealed class MTConnectDriver
|
|||||||
MTConnectProbeModel model;
|
MTConnectProbeModel model;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
model = await BoundedAsync(session.Client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken)
|
model = await BoundedAsync(
|
||||||
|
session.Client.ProbeAsync, "/probe", Volatile.Read(ref _options).RequestTimeoutMs, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (ObjectDisposedException ex)
|
catch (ObjectDisposedException ex)
|
||||||
@@ -2652,7 +2678,7 @@ public sealed class MTConnectDriver
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// ---- commit point: everything below is non-throwing bookkeeping ----
|
// ---- commit point: everything below is non-throwing bookkeeping ----
|
||||||
_options = options;
|
Volatile.Write(ref _options, options);
|
||||||
|
|
||||||
// Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
|
// Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
|
||||||
// blob declares no type takes the type the Agent itself declares for that DataItem, so a
|
// blob declares no type takes the type the Agent itself declares for that DataItem, so a
|
||||||
@@ -2904,7 +2930,7 @@ public sealed class MTConnectDriver
|
|||||||
{
|
{
|
||||||
return HasConfigBody(driverConfigJson)
|
return HasConfigBody(driverConfigJson)
|
||||||
? ParseOptions(_driverInstanceId, driverConfigJson)
|
? ParseOptions(_driverInstanceId, driverConfigJson)
|
||||||
: _options;
|
: Volatile.Read(ref _options);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -3013,7 +3039,7 @@ public sealed class MTConnectDriver
|
|||||||
{
|
{
|
||||||
// A bare "A task was canceled" names neither the Agent nor the request that stalled.
|
// A bare "A task was canceled" names neither the Agent nor the request that stalled.
|
||||||
throw new TimeoutException(
|
throw new TimeoutException(
|
||||||
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{_options.AgentUri}' did not complete within {timeoutMs} ms.");
|
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{Volatile.Read(ref _options).AgentUri}' did not complete within {timeoutMs} ms.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3050,7 +3076,7 @@ public sealed class MTConnectDriver
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
ex,
|
ex,
|
||||||
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
|
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
|
||||||
_driverInstanceId, _options.AgentUri);
|
_driverInstanceId, Volatile.Read(ref _options).AgentUri);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Publishes <see cref="DriverState.Degraded"/> without downgrading a Faulted driver.</summary>
|
/// <summary>Publishes <see cref="DriverState.Degraded"/> without downgrading a Faulted driver.</summary>
|
||||||
|
|||||||
+54
-3
@@ -3,7 +3,9 @@
|
|||||||
paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
|
paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
|
||||||
there is no host/port to split onto the device.
|
there is no host/port to split onto the device.
|
||||||
|
|
||||||
MTConnect is read-only — there are no write knobs to author. *@
|
Write-back is authored here too (Driver.MTConnect-004): the driver gained IWritable behind
|
||||||
|
write.enabled, and until this section existed the documented enablement path was a hand edit of
|
||||||
|
DriverInstance.DriverConfig in the config DB. *@
|
||||||
@using System.Text.Json
|
@using System.Text.Json
|
||||||
@using System.Text.Json.Nodes
|
@using System.Text.Json.Nodes
|
||||||
@using System.Text.Json.Serialization
|
@using System.Text.Json.Serialization
|
||||||
@@ -109,6 +111,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@* Write-back *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||||
|
<div class="panel-head">Write-back</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<InputCheckbox @bind-Value="_form.WriteEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="mtcWriteEnabled" />
|
||||||
|
<label class="form-check-label" for="mtcWriteEnabled">Allow write-back</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-0">Default off.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="form-text">
|
||||||
|
An MTConnect write is <strong>observation injection</strong>: it sets what the Agent
|
||||||
|
<em>reports</em> for a DataItem, not the machine itself, and the next adapter update overwrites it.
|
||||||
|
<br />
|
||||||
|
This is only one of two gates — the Agent's own <span class="mono">AllowPut</span> (its
|
||||||
|
<span class="mono">agent.cfg</span>) must be on as well, and neither gate can arm the other.
|
||||||
|
Turning this on does not by itself make any node writable over OPC UA: the tag's
|
||||||
|
<span class="mono">AccessLevel</span> still composes the <span class="mono">CurrentWrite</span> bit.
|
||||||
|
CONDITION items are never writable.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@if (_form.Validate() is { } error)
|
@if (_form.Validate() is { } error)
|
||||||
{
|
{
|
||||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
|
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
|
||||||
@@ -165,8 +195,8 @@
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
|
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
|
||||||
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
|
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
|
||||||
/// silently delete). The <c>probe</c> and <c>reconnect</c> sub-objects are replaced wholesale
|
/// silently delete). The <c>probe</c>, <c>reconnect</c> and <c>write</c> sub-objects are
|
||||||
/// — the form owns every field in both.
|
/// replaced wholesale — the form owns every field in all three.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
|
/// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
|
||||||
@@ -268,6 +298,12 @@
|
|||||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||||
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
|
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
|
||||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the driver's half of the write-back gate is armed. Default <c>false</c> — the
|
||||||
|
/// driver shipped read-only, and the Agent's own <c>AllowPut</c> is an independent second
|
||||||
|
/// gate that this cannot arm.
|
||||||
|
/// </summary>
|
||||||
|
public bool WriteEnabled { get; set; }
|
||||||
|
|
||||||
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
|
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
|
||||||
/// own defaults for anything absent or unusable.</summary>
|
/// own defaults for anything absent or unusable.</summary>
|
||||||
@@ -293,6 +329,7 @@
|
|||||||
ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
|
ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
|
||||||
ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
|
ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
|
||||||
ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
|
ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
|
||||||
|
WriteEnabled = dto.Write?.Enabled ?? defaults.WriteEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +356,11 @@
|
|||||||
MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
|
MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
|
||||||
BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
|
BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Written even when false, unlike the timing knobs: those are omitted so the driver
|
||||||
|
// substitutes its own positive default, whereas an omitted write block would leave a
|
||||||
|
// previously-authored "enabled": true standing and the switch could never turn it off.
|
||||||
|
Write = new MTConnectWriteConfigDto { Enabled = WriteEnabled },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Returns an operator-facing message describing why this config would not start, or
|
/// <summary>Returns an operator-facing message describing why this config would not start, or
|
||||||
@@ -380,6 +422,8 @@
|
|||||||
public MTConnectProbeConfigDto? Probe { get; init; }
|
public MTConnectProbeConfigDto? Probe { get; init; }
|
||||||
/// <summary>Reconnect backoff knobs.</summary>
|
/// <summary>Reconnect backoff knobs.</summary>
|
||||||
public MTConnectReconnectConfigDto? Reconnect { get; init; }
|
public MTConnectReconnectConfigDto? Reconnect { get; init; }
|
||||||
|
/// <summary>Write-back knobs — the driver's half of the two-layer opt-in on observation injection.</summary>
|
||||||
|
public MTConnectWriteConfigDto? Write { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Wire mirror of the driver's probe config block.</summary>
|
/// <summary>Wire mirror of the driver's probe config block.</summary>
|
||||||
@@ -403,4 +447,11 @@
|
|||||||
/// <summary>Multiplier applied each retry.</summary>
|
/// <summary>Multiplier applied each retry.</summary>
|
||||||
public double? BackoffMultiplier { get; init; }
|
public double? BackoffMultiplier { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wire mirror of the driver's write-back config block.</summary>
|
||||||
|
public sealed class MTConnectWriteConfigDto
|
||||||
|
{
|
||||||
|
/// <summary>Whether the driver accepts write-back at all.</summary>
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
||||||
public Exception? WriteFailure { get; set; }
|
public Exception? WriteFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ran after a write has been recorded and before its outcome is returned. The deterministic
|
||||||
|
/// injection point for something that must happen BETWEEN two slots of one batch — cancelling
|
||||||
|
/// the batch's token, for instance — with no timing hope involved.
|
||||||
|
/// </summary>
|
||||||
|
public Action? AfterWrite { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||||
@@ -377,6 +384,7 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
Writes.Enqueue((deviceName, dataItemId, value));
|
Writes.Enqueue((deviceName, dataItemId, value));
|
||||||
|
AfterWrite?.Invoke();
|
||||||
|
|
||||||
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
@@ -353,4 +354,54 @@ public sealed class MTConnectAgentClientWriteTests
|
|||||||
await Should.ThrowAsync<ObjectDisposedException>(
|
await Should.ThrowAsync<ObjectDisposedException>(
|
||||||
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.MTConnect-007 — concurrent disposals must not throw out of <c>Dispose</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A plain check-then-set on a non-volatile <c>bool</c> lets two racing disposals both pass
|
||||||
|
/// the guard; the loser then calls <c>Cancel()</c> on a <see cref="CancellationTokenSource"/>
|
||||||
|
/// the winner has already disposed, which throws <see cref="ObjectDisposedException"/> out of
|
||||||
|
/// <c>Dispose</c>. The driver disposes this client under its lifecycle lock and through
|
||||||
|
/// <c>SafeDispose</c>, so the throw is caught and traced today — but "the caller happens to
|
||||||
|
/// catch it" is not idempotence, and nothing on this type says disposal is single-threaded.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The window is a handful of instructions, so one pass proves nothing</b> — the racers
|
||||||
|
/// are released together on a barrier and the whole thing is repeated. Against the
|
||||||
|
/// unguarded field this fails within a few hundred rounds (observed: round 690); once the
|
||||||
|
/// guard is an <see cref="Interlocked.Exchange(ref int, int)"/> no schedule can fail it, so
|
||||||
|
/// it cannot flake either way.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_disposals_are_idempotent()
|
||||||
|
{
|
||||||
|
const int racers = 8;
|
||||||
|
|
||||||
|
for (var round = 0; round < 3000; round++)
|
||||||
|
{
|
||||||
|
var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
|
||||||
|
using var start = new Barrier(racers);
|
||||||
|
var failures = new ConcurrentBag<Exception>();
|
||||||
|
|
||||||
|
await Task.WhenAll(Enumerable.Range(0, racers).Select(_ => Task.Run(() =>
|
||||||
|
{
|
||||||
|
start.SignalAndWait();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failures.Add(ex);
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
failures.ShouldBeEmpty(
|
||||||
|
$"Dispose must be idempotent under a race (round {round}); "
|
||||||
|
+ string.Join(" | ", failures.Select(e => e.GetType().Name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,6 +382,33 @@ public sealed class MTConnectFactoryTests
|
|||||||
.DriverInstanceId.ShouldBe("mt1");
|
.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>
|
/// <summary>
|
||||||
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
|
/// 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
|
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.MTConnect-005 — <c>MTConnectDriver._options</c> must be published and read the way its
|
||||||
|
/// three siblings at the same commit point are (<c>_session</c>, <c>_binding</c>,
|
||||||
|
/// <c>_announcedInstanceId</c>): <c>Volatile.Write</c> once, <c>Volatile.Read</c> everywhere else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why a source scan and not a behavioural test.</b> The defect is a missing memory fence:
|
||||||
|
/// a plain field write publishes the new options document with no release, and a reader that
|
||||||
|
/// reads <c>_options</c> BEFORE it reads <c>_binding</c> — which both <c>ReadAsync</c> and
|
||||||
|
/// <c>WriteAsync</c> do — has no acquire ordering between the two, so it can in principle
|
||||||
|
/// judge a batch against the previous <c>Write.Enabled</c> / <c>RequestTimeoutMs</c> paired
|
||||||
|
/// with the new tag table. No test can force that interleaving on x86/arm64 in bounded time;
|
||||||
|
/// what IS checkable, and what actually regresses, is whether a new read site was written
|
||||||
|
/// with the fence. This is the same shape as
|
||||||
|
/// <c>LibplctagGatewayConsumptionGuardTests</c>: the defect recurs by being reintroduced at a
|
||||||
|
/// call site no per-site test covers.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The floor below exists so the scan cannot go blind. If the count drops, the field was
|
||||||
|
/// renamed or the sites were consolidated — re-derive the floor from the code; never lower it
|
||||||
|
/// to make this pass.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MTConnectOptionsPublicationTests
|
||||||
|
{
|
||||||
|
/// <summary>Any mention of the field, so an unguarded read is caught as well as an unguarded write.</summary>
|
||||||
|
private static readonly Regex Mention = new(@"_options\b", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>The two guarded forms. Nothing else may touch the field.</summary>
|
||||||
|
private static readonly Regex Guarded =
|
||||||
|
new(@"Volatile\.(Read|Write)\(ref _options[,)]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>The declaration itself, which is neither a read nor a write.</summary>
|
||||||
|
private static readonly Regex Declaration =
|
||||||
|
new(@"^\s*private\s+MTConnectDriverOptions\s+_options\s*;\s*$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_touch_of_the_shared_options_field_goes_through_Volatile()
|
||||||
|
{
|
||||||
|
var source = File.ReadAllLines(Path.Combine(
|
||||||
|
LocateRepoRoot(), "src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.MTConnect", "MTConnectDriver.cs"));
|
||||||
|
|
||||||
|
var offending = new List<string>();
|
||||||
|
var guarded = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < source.Length; i++)
|
||||||
|
{
|
||||||
|
var line = source[i];
|
||||||
|
var code = StripComment(line);
|
||||||
|
|
||||||
|
if (!Mention.IsMatch(code) || Declaration.IsMatch(code))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mentions = Mention.Matches(code).Count;
|
||||||
|
var fenced = Guarded.Matches(code).Count;
|
||||||
|
guarded += fenced;
|
||||||
|
|
||||||
|
if (fenced < mentions)
|
||||||
|
{
|
||||||
|
offending.Add($"line {i + 1}: {line.Trim()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offending.ShouldBeEmpty(
|
||||||
|
"MTConnectDriver._options is shared state read lock-free from the read, write, discovery "
|
||||||
|
+ "and health paths while a re-initialize replaces it. Publish it with Volatile.Write and "
|
||||||
|
+ "read it with Volatile.Read, exactly as _session / _binding / _announcedInstanceId are "
|
||||||
|
+ $"(Driver.MTConnect-005). Unfenced: {string.Join(" | ", offending)}");
|
||||||
|
|
||||||
|
guarded.ShouldBeGreaterThanOrEqualTo(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes a trailing line comment and skips XML-doc lines, which mention the field by name.</summary>
|
||||||
|
private static string StripComment(string line)
|
||||||
|
{
|
||||||
|
var trimmed = line.TrimStart();
|
||||||
|
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("*", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var comment = line.IndexOf("//", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
return comment >= 0 ? line[..comment] : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateRepoRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||||
|
{
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
dir.ShouldNotBeNull("Could not locate the repo root (ZB.MOM.WW.OtOpcUa.slnx) above the test assembly.");
|
||||||
|
|
||||||
|
return dir.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,6 +191,57 @@ public sealed class MTConnectWriteTests
|
|||||||
client.Writes.ShouldBeEmpty();
|
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]
|
[Fact]
|
||||||
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
|
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
|
||||||
{
|
{
|
||||||
@@ -358,16 +409,19 @@ public sealed class MTConnectWriteTests
|
|||||||
// The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against
|
// 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
|
// 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
|
// an operator a write failed that actually landed — the one thing worse than reporting a
|
||||||
// failure. Cancellation is injected from inside slot 1's own formatting, so the ordering is 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.
|
// 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();
|
var (driver, client) = await WritableDriverAsync();
|
||||||
using var cts = new CancellationTokenSource();
|
using var cts = new CancellationTokenSource();
|
||||||
|
client.AfterWrite = () => cts.Cancel();
|
||||||
|
|
||||||
var results = await driver.WriteAsync(
|
var results = await driver.WriteAsync(
|
||||||
[
|
[
|
||||||
new WriteRequest(PositionRawPath, 42.5),
|
new WriteRequest(PositionRawPath, 42.5),
|
||||||
new WriteRequest(PositionRawPath, new CancellingFormatter(cts)),
|
|
||||||
new WriteRequest(PositionRawPath, 7.5),
|
new WriteRequest(PositionRawPath, 7.5),
|
||||||
|
new WriteRequest(PositionRawPath, 9.5),
|
||||||
],
|
],
|
||||||
cts.Token);
|
cts.Token);
|
||||||
|
|
||||||
@@ -380,17 +434,6 @@ public sealed class MTConnectWriteTests
|
|||||||
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
|
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Cancels the batch from inside its own rendering, then renders normally.</summary>
|
|
||||||
private sealed class CancellingFormatter(CancellationTokenSource cts)
|
|
||||||
{
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
cts.Cancel();
|
|
||||||
|
|
||||||
return "99";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
|
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,6 +128,95 @@ public sealed class MTConnectDriverFormModelTests
|
|||||||
o["reconnect"]!["minBackoffMs"]!.GetValue<int>().ShouldBe(0);
|
o["reconnect"]!["minBackoffMs"]!.GetValue<int>().ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- write-back (Driver.MTConnect-004) -----------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_back_is_off_by_default_and_is_written_explicitly()
|
||||||
|
{
|
||||||
|
// The capability shipped 2026-08-05 behind write.enabled and docs/drivers/MTConnect.md tells
|
||||||
|
// the operator to set it — but the form authored no write block at all, so the documented
|
||||||
|
// enablement path existed only as a hand edit of DriverInstance.DriverConfig in the config DB.
|
||||||
|
//
|
||||||
|
// The default is written rather than omitted: the driver reads an absent block as disabled
|
||||||
|
// either way, and an explicit "enabled": false is what makes a config an operator opened and
|
||||||
|
// saved say what it does. It is also what lets the form TURN THE FLAG OFF again — an omitted
|
||||||
|
// key would leave a previously-authored true standing.
|
||||||
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" });
|
||||||
|
|
||||||
|
o["write"]!["enabled"]!.GetValue<bool>().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_back_enabled_reaches_the_key_the_driver_binds()
|
||||||
|
{
|
||||||
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
|
||||||
|
{
|
||||||
|
AgentUri = "http://agent:5000",
|
||||||
|
WriteEnabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// MTConnectDriver.ParseOptions binds write.enabled into MTConnectDriverOptions.Write.Enabled
|
||||||
|
// (pinned driver-side by MTConnectFactoryTests). This is the AdminUI half of that contract.
|
||||||
|
o["write"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_back_round_trips_and_a_hand_authored_block_loads_into_the_form()
|
||||||
|
{
|
||||||
|
// The mitigating property the finding recorded — BuildConfigJson preserves unknown TOP-LEVEL
|
||||||
|
// keys, so a hand-authored write block survived an edit through the form — is not enough on
|
||||||
|
// its own: unless the form READS it, opening the modal shows the switch off over a config
|
||||||
|
// that has writing on, and the operator's next save is the one that finally clears it.
|
||||||
|
const string handAuthored = """{"agentUri":"http://agent:5000","write":{"enabled":true}}""";
|
||||||
|
|
||||||
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(handAuthored);
|
||||||
|
form.WriteEnabled.ShouldBeTrue();
|
||||||
|
|
||||||
|
var again = MTConnectDriverForm.MTConnectFormModel.FromJson(
|
||||||
|
MTConnectDriverForm.BuildConfigJson(form, handAuthored));
|
||||||
|
again.WriteEnabled.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Turning_write_back_off_clears_a_previously_authored_true()
|
||||||
|
{
|
||||||
|
// The falsification control for the "always write the block" decision above: a form that
|
||||||
|
// omitted the key when false would leave the stored true in place, so the switch would appear
|
||||||
|
// to turn writing off while the deployed driver went on accepting writes.
|
||||||
|
const string existing = """{"agentUri":"http://agent:5000","write":{"enabled":true}}""";
|
||||||
|
|
||||||
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
|
||||||
|
form.WriteEnabled = false;
|
||||||
|
|
||||||
|
Emit(form, existing)["write"]!["enabled"]!.GetValue<bool>().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_reads_a_pascal_case_write_block()
|
||||||
|
{
|
||||||
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(
|
||||||
|
"""{"AgentUri":"http://agent:5000","Write":{"Enabled":true}}""");
|
||||||
|
|
||||||
|
form.WriteEnabled.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_write_enabled_document_is_the_literal_the_driver_test_parses()
|
||||||
|
{
|
||||||
|
// The AdminUI cannot be referenced from a driver test project, so the driver-side leg of this
|
||||||
|
// contract (MTConnectFactoryTests.The_document_the_AdminUI_form_emits_arms_write_back) parses a
|
||||||
|
// literal copy of this form's output. Asserting the bytes here is what stops the two drifting:
|
||||||
|
// a rename or a reordering on this side fails HERE, naming the literal that must be updated.
|
||||||
|
MTConnectDriverForm.BuildConfigJson(
|
||||||
|
new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", WriteEnabled = true },
|
||||||
|
null)
|
||||||
|
.ShouldBe(WriteEnabledDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form's output for a write-enabled instance, mirrored in the driver test suite.</summary>
|
||||||
|
private const string WriteEnabledDocument =
|
||||||
|
"""{"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}}""";
|
||||||
|
|
||||||
// ---- Validate ------------------------------------------------------------------------------
|
// ---- Validate ------------------------------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -292,6 +381,8 @@ public sealed class MTConnectDriverFormModelTests
|
|||||||
dto.Reconnect!.MinBackoffMs.ShouldBe(0);
|
dto.Reconnect!.MinBackoffMs.ShouldBe(0);
|
||||||
dto.Reconnect.MaxBackoffMs.ShouldBe(30000);
|
dto.Reconnect.MaxBackoffMs.ShouldBe(30000);
|
||||||
dto.Reconnect.BackoffMultiplier.ShouldBe(2.0);
|
dto.Reconnect.BackoffMultiplier.ShouldBe(2.0);
|
||||||
|
dto.Write.ShouldNotBeNull();
|
||||||
|
dto.Write!.Enabled.ShouldBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class WireDto
|
private sealed class WireDto
|
||||||
@@ -304,6 +395,12 @@ public sealed class MTConnectDriverFormModelTests
|
|||||||
public int? HeartbeatMs { get; init; }
|
public int? HeartbeatMs { get; init; }
|
||||||
public WireProbe? Probe { get; init; }
|
public WireProbe? Probe { get; init; }
|
||||||
public WireReconnect? Reconnect { get; init; }
|
public WireReconnect? Reconnect { get; init; }
|
||||||
|
public WireWrite? Write { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WireWrite
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class WireProbe
|
private sealed class WireProbe
|
||||||
|
|||||||
Reference in New Issue
Block a user