feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker 53f69cd — all four unary write kinds), so every write reply's statuses[0] now carries the real COM commit outcome. Live-gating the cutover on windev found and fixed three defects: - StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a REFUSED write to Good. New invariant: a row that is not IsSuccess() never maps Good — details > 0xFF (and contradictory Good-band substatuses) route by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes). - The borrow-from-subscription-registry write optimization could never commit: a subscription handle is plain-advised, and MXAccess refuses both the no-login write through it (SECURITY_ERROR detail 1008, live-captured) and the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not per attribute. Removed: writer subscribedHandleSource ctor param + borrow branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle + its forward index and test file. The writer always AddItems its own supervisory-advised handle, which commits fine while the same attribute is subscribed (live-proven; pinned by the new write-while-subscribed smoke). - (gateway-side, fixed there) the correlation initially covered WriteSecured only, while the dominant no-login path emits plain Write. Live gate, all legs PASSED against windev: happy write Good off a confirmed status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only refusal surfaces Bad-family (the #5 node-revert input Galaxy could never produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good + metered unconfirmed (override removed after, restoration re-verified). New env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal, Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are process-wide).
This commit is contained in:
+6
-92
@@ -89,96 +89,10 @@ public sealed class GatewayGalaxyDataWriterTests
|
||||
writer.CachedSupervisedHandleCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ===== subscribedHandleSource / TryResolveCachedOrBorrowed tests =====
|
||||
|
||||
/// <summary>
|
||||
/// When a <c>subscribedHandleSource</c> delegate returns a positive handle for the
|
||||
/// requested ref, <see cref="GatewayGalaxyDataWriter.TryResolveCachedOrBorrowed"/> must
|
||||
/// return that handle. Critically, the borrowed handle must NOT be stored in the writer's
|
||||
/// own item-handle cache (the subscription registry owns its lifecycle), and
|
||||
/// <see cref="GatewayGalaxyDataWriter.AddItemCallCount"/> must remain zero (no real
|
||||
/// gateway round-trip issued).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryResolveCachedOrBorrowed_returns_borrowed_handle_without_caching_it()
|
||||
{
|
||||
var writer = new GatewayGalaxyDataWriter(
|
||||
MinimalSession(), writeUserId: 0, logger: null,
|
||||
subscribedHandleSource: fr => fr == "Tag.X" ? 7 : (int?)null);
|
||||
|
||||
var result = writer.TryResolveCachedOrBorrowed("Tag.X");
|
||||
|
||||
result.ShouldBe(7);
|
||||
writer.CachedItemHandleCount.ShouldBe(0); // borrow NOT stored in _itemHandles
|
||||
writer.AddItemCallCount.ShouldBe(0); // no real AddItem issued
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a handle is present in the writer's own <c>_itemHandles</c> cache (seeded via
|
||||
/// <see cref="GatewayGalaxyDataWriter.SeedHandleCachesForTest"/>), that cached handle must
|
||||
/// win over the <c>subscribedHandleSource</c> delegate — the writer's own earlier AddItem
|
||||
/// takes priority.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryResolveCachedOrBorrowed_cached_handle_wins_over_source()
|
||||
{
|
||||
// Source would return 99 for "Tag.Y", but the writer already cached handle 42 for it.
|
||||
var writer = new GatewayGalaxyDataWriter(
|
||||
MinimalSession(), writeUserId: 0, logger: null,
|
||||
subscribedHandleSource: fr => fr == "Tag.Y" ? 99 : (int?)null);
|
||||
|
||||
writer.SeedHandleCachesForTest("Tag.Y", itemHandle: 42, supervised: false);
|
||||
|
||||
var result = writer.TryResolveCachedOrBorrowed("Tag.Y");
|
||||
|
||||
result.ShouldBe(42); // cached wins, not 99 from the source
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When no <c>subscribedHandleSource</c> is provided (<c>null</c>), the method must
|
||||
/// return <c>null</c> for a ref that has never been cached — preserving today's
|
||||
/// AddItem-required behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryResolveCachedOrBorrowed_null_source_returns_null_when_not_cached()
|
||||
{
|
||||
var writer = new GatewayGalaxyDataWriter(MinimalSession(), writeUserId: 0);
|
||||
|
||||
var result = writer.TryResolveCachedOrBorrowed("Tag.Z");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>subscribedHandleSource</c> that returns 0 (a sentinel meaning "subscribe
|
||||
/// pending / failed") must be treated as no borrow — the method returns <c>null</c>
|
||||
/// so the caller issues a fresh AddItem.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryResolveCachedOrBorrowed_source_returning_zero_treated_as_no_borrow()
|
||||
{
|
||||
var writer = new GatewayGalaxyDataWriter(
|
||||
MinimalSession(), writeUserId: 0, logger: null,
|
||||
subscribedHandleSource: _ => 0);
|
||||
|
||||
var result = writer.TryResolveCachedOrBorrowed("Tag.W");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>subscribedHandleSource</c> that returns a negative value (another failure
|
||||
/// sentinel) must also be treated as no borrow — the method returns <c>null</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryResolveCachedOrBorrowed_source_returning_negative_treated_as_no_borrow()
|
||||
{
|
||||
var writer = new GatewayGalaxyDataWriter(
|
||||
MinimalSession(), writeUserId: 0, logger: null,
|
||||
subscribedHandleSource: _ => -5);
|
||||
|
||||
var result = writer.TryResolveCachedOrBorrowed("Tag.V");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
// The former subscribedHandleSource / TryResolveCachedOrBorrowed tests are gone with the
|
||||
// borrow-for-write optimization itself: a subscription's handle is plain-advised, and MXAccess
|
||||
// both refuses a no-login write through it (SECURITY_ERROR detail 1008) and refuses upgrading
|
||||
// its advise mode (E_INVALIDARG) — live-captured on the correlated write replies (06/S-1). The
|
||||
// writer now always AddItems its own supervisory-advised handle, which MXAccess accepts even
|
||||
// while the same attribute is subscribed (the advise limit is per handle, not per attribute).
|
||||
}
|
||||
|
||||
+69
-28
@@ -125,52 +125,43 @@ public sealed class GatewayGalaxyLiveReopenAndWriteTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Borrow</b> — the writer reuses a live MXAccess item handle the subscription registry
|
||||
/// already holds, so the first write to an already-subscribed tag skips a redundant AddItem.
|
||||
/// Proves the load-bearing premise (a subscription handle is usable for a no-login supervisory
|
||||
/// write that commits) AND the optimization (zero AddItem on the borrow path). Control: a writer
|
||||
/// with no registry source on the same session AddItems exactly once.
|
||||
/// <b>Write-while-subscribed</b> — the writer must commit a no-login supervisory write to a
|
||||
/// tag that is ALREADY live-subscribed on the same session, via its own AddItem'd handle.
|
||||
/// This replaces the retired borrow smoke: the borrow optimization's premise ("a subscription
|
||||
/// handle is usable for a committing no-login write") was disproven by the correlated write
|
||||
/// replies — a plain-advised subscription handle refuses the write (SECURITY_ERROR detail
|
||||
/// 1008) and refuses an advise upgrade (E_INVALIDARG). What this pins instead is the fix's
|
||||
/// premise: MXAccess's one-advise-per-handle limit is per HANDLE, not per attribute, so the
|
||||
/// writer's own supervisory-advised handle commits while the subscription keeps its own.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Live_writer_borrows_subscription_handle_and_skips_AddItem()
|
||||
public async Task Live_write_to_subscribed_tag_commits_via_own_handle()
|
||||
{
|
||||
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var clientOptions = BuildClientOptions(endpoint, apiKey);
|
||||
|
||||
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaBorrowSmoke"));
|
||||
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUaSubWriteSmoke"));
|
||||
await session.ConnectAsync(clientOptions, ct);
|
||||
|
||||
// Subscribe the tag so the registry holds the gateway's REAL item handle for it.
|
||||
// Live subscription on the tag — the state the borrow path used to piggyback on.
|
||||
var subscriber = new GatewayGalaxySubscriber(session);
|
||||
var subResults = await subscriber.SubscribeBulkAsync([WriteRef], bufferedUpdateIntervalMs: 0, ct);
|
||||
var match = subResults.SingleOrDefault(r =>
|
||||
string.Equals(r.TagAddress, WriteRef, StringComparison.OrdinalIgnoreCase) && r.WasSuccessful);
|
||||
match.ShouldNotBeNull($"the gateway should accept a subscription on {WriteRef}");
|
||||
match!.ItemHandle.ShouldBeGreaterThan(0, "a successful subscribe must return a positive item handle");
|
||||
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding(WriteRef, match.ItemHandle)]);
|
||||
|
||||
// Borrow case: a writer wired to the registry must commit the write WITHOUT a fresh AddItem,
|
||||
// and must NOT cache the borrowed handle (the registry owns its lifecycle).
|
||||
var borrowingWriter = new GatewayGalaxyDataWriter(
|
||||
session, writeUserId: 0, logger: null, subscribedHandleSource: registry.TryResolveItemHandle);
|
||||
var borrowed = await borrowingWriter.WriteAsync(
|
||||
// The production writer must AddItem its own handle and commit, despite the live subscription.
|
||||
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
||||
var result = await writer.WriteAsync(
|
||||
[new WriteRequest(WriteRef, 2727.0f)], _ => SecurityClassification.FreeAccess, ct);
|
||||
borrowed.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the borrowed-handle write should commit Good (0)");
|
||||
borrowingWriter.AddItemCallCount.ShouldBe(0, "the writer should reuse the subscription handle, not AddItem");
|
||||
borrowingWriter.CachedItemHandleCount.ShouldBe(0, "a borrowed handle must not be cached in the writer");
|
||||
|
||||
// Control: a writer with NO source on the same session must AddItem exactly once for the same tag.
|
||||
var plainWriter = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
||||
var control = await plainWriter.WriteAsync(
|
||||
[new WriteRequest(WriteRef, 3838.0f)], _ => SecurityClassification.FreeAccess, ct);
|
||||
control.ShouldHaveSingleItem().StatusCode.ShouldBe(0u, "the control write should commit Good (0)");
|
||||
plainWriter.AddItemCallCount.ShouldBe(1, "with no borrow source the writer must AddItem once");
|
||||
result.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
|
||||
"a write to an already-subscribed tag must commit Good via the writer's own handle");
|
||||
writer.AddItemCallCount.ShouldBe(1, "the writer owns its handle — exactly one AddItem");
|
||||
writer.CachedSupervisedHandleCount.ShouldBe(1, "the writer's own handle should be supervisory-advised");
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"borrow smoke: subscribed {WriteRef} -> handle {match.ItemHandle}; borrowed write Good with AddItemCallCount=0; control AddItemCallCount=1");
|
||||
$"write-while-subscribed smoke: subscribed {WriteRef} (handle {match!.ItemHandle}); own-handle write Good, AddItemCallCount=1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -201,6 +192,56 @@ public sealed class GatewayGalaxyLiveReopenAndWriteTests
|
||||
$"fail-closed guard smoke: normal advised write {WriteRef}=5151 returned Good; handle supervised");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 06/S-1 confirmation smoke — the gateway (mxaccessgw ≥ <c>53f69cd</c>) correlates the worker's
|
||||
/// <c>OnWriteComplete</c> onto the unary write reply as <c>statuses[0]</c>, so the production
|
||||
/// writer's verdict is now the real COM commit outcome, not worker-side acceptance:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>a committed write returns Good off a CONFIRMED status row
|
||||
/// (<c>success:-1, category:OK</c>) — pre-correlation this was a provisional Good off an
|
||||
/// empty statuses array;</description></item>
|
||||
/// <item><description>a REFUSED write (read-only attribute) returns a Bad-family code off
|
||||
/// the correlated failure row (live shape: <c>category:OPERATIONAL_ERROR, detail:1007</c>)
|
||||
/// — the capability that never existed before: pre-correlation the refusal was invisible
|
||||
/// and reported Good, so the #5 write-outcome self-correction never fired for Galaxy.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// Requires both deployed gateway halves at the correlation build (server ≥ b948e69, worker ≥
|
||||
/// 53f69cd); against an older gateway the refusal leg fails — that is the point of the gate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Live_correlated_write_statuses_confirm_commit_and_surface_refusal()
|
||||
{
|
||||
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var clientOptions = BuildClientOptions(endpoint, apiKey);
|
||||
|
||||
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Smoke"));
|
||||
await session.ConnectAsync(clientOptions, ct);
|
||||
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
||||
|
||||
// Happy path: the confirmed commit comes back Good (statuses[0] success row, not empty-array
|
||||
// provisional). The commit itself is proven by Live_supervisory_write_commits_and_persists_to_galaxy.
|
||||
var good = await writer.WriteAsync(
|
||||
[new WriteRequest(WriteRef, 6262.0f)], _ => SecurityClassification.FreeAccess, ct);
|
||||
good.ShouldHaveSingleItem().StatusCode.ShouldBe(0u,
|
||||
"a committed write should return Good off the correlated status row");
|
||||
|
||||
// Refusal path: Tagname is read-only, so the automation object refuses the write and the
|
||||
// correlated failure row must surface as a Bad-family code (top two bits 10b). Pre-correlation
|
||||
// this returned Good. Assert the family, not the exact code — the detail→code mapping may
|
||||
// gain per-code specificity (StatusCodeMap.FromCategory) without invalidating the gate.
|
||||
var refused = await writer.WriteAsync(
|
||||
[new WriteRequest("TestMachine_002.Tagname", "refused-write-probe")],
|
||||
_ => SecurityClassification.FreeAccess, ct);
|
||||
var refusedCode = refused.ShouldHaveSingleItem().StatusCode;
|
||||
((refusedCode >> 30) & 0x3u).ShouldBe(2u,
|
||||
$"a refused write must surface a Bad-family code off the correlated failure row (got 0x{refusedCode:X8})");
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"06/S-1 smoke: {WriteRef}=6262 -> Good confirmed; Tagname write refused with 0x{refusedCode:X8}");
|
||||
}
|
||||
|
||||
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Live meter legs for the 06/S-1 write-confirmation gate — the observation that closed the
|
||||
/// archreview finding: against a correlated gateway (mxaccessgw server ≥ <c>b948e69</c>, worker ≥
|
||||
/// <c>53f69cd</c>) every unary write reply carries the worker's <c>OnWriteComplete</c> outcome as
|
||||
/// <c>statuses[0]</c>, so <c>galaxy.writes.unconfirmed</c> — which used to increment on EVERY
|
||||
/// write — stays flat, and only a completion-wait timeout (or a pre-correlation gateway) meters.
|
||||
/// <para>
|
||||
/// ⚠️ Run these FILTERED (one test class), never as part of a full-assembly run with the env vars
|
||||
/// set: the offline <see cref="GatewayGalaxyDataWriterTests"/> exercise the unconfirmed path
|
||||
/// against fakes, and xunit runs test classes in parallel, so their increments would pollute the
|
||||
/// process-wide counters these tests listen to.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class GatewayGalaxyWriteMeterLiveTests
|
||||
{
|
||||
private const string WriteRef = "TestMachine_002.TestFloat";
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed writes keep both write meters flat: five committed writes and one refusal, all
|
||||
/// carrying a correlated status row — zero <c>unconfirmed</c> (the counter is presence-based,
|
||||
/// so the refusal must not increment it either) and zero <c>advise_failed</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Live_confirmed_writes_keep_unconfirmed_meter_flat()
|
||||
{
|
||||
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
||||
|
||||
long unconfirmed = 0;
|
||||
long adviseFailed = 0;
|
||||
using var listener = new System.Diagnostics.Metrics.MeterListener();
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Name is "galaxy.writes.unconfirmed" or "galaxy.writes.advise_failed")
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, _, _) =>
|
||||
{
|
||||
if (instrument.Name == "galaxy.writes.unconfirmed") Interlocked.Add(ref unconfirmed, value);
|
||||
else Interlocked.Add(ref adviseFailed, value);
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Meter"));
|
||||
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
|
||||
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var r = await writer.WriteAsync(
|
||||
[new WriteRequest(WriteRef, 7000.0f + i)], _ => SecurityClassification.FreeAccess, ct);
|
||||
Assert.Equal(0u, r[0].StatusCode);
|
||||
}
|
||||
var refused = await writer.WriteAsync(
|
||||
[new WriteRequest("TestMachine_002.Tagname", "meter-leg-refusal")],
|
||||
_ => SecurityClassification.FreeAccess, ct);
|
||||
Assert.Equal(2u, (refused[0].StatusCode >> 30) & 0x3u);
|
||||
|
||||
listener.Dispose();
|
||||
Assert.Equal(0, Interlocked.Read(ref unconfirmed));
|
||||
Assert.Equal(0, Interlocked.Read(ref adviseFailed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout-control leg — run ONLY while the target gateway has the completion wait disabled
|
||||
/// (<c>MxGateway__Worker__WriteCompletionWaitMilliseconds=0</c>, an NSSM env-var flip +
|
||||
/// service restart on a deployed instance): every reply is then empty-statuses, so the writer
|
||||
/// reports provisional Good and meters each write unconfirmed. Gated on
|
||||
/// <c>MXGW_WRITE_WAIT_DISABLED=1</c> so it can never run (and fail) against a
|
||||
/// normally-configured gateway. Remove the override (same nssm command with <c>-</c>) and
|
||||
/// restart afterwards; <see cref="Live_confirmed_writes_keep_unconfirmed_meter_flat"/>
|
||||
/// passing again proves the restoration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Live_wait_disabled_gateway_yields_provisional_good_and_metered_unconfirmed()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("MXGW_WRITE_WAIT_DISABLED") != "1")
|
||||
Assert.Skip("Set MXGW_WRITE_WAIT_DISABLED=1 (with the gateway's completion wait set to 0) to run the timeout leg.");
|
||||
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
||||
|
||||
long unconfirmed = 0;
|
||||
using var listener = new System.Diagnostics.Metrics.MeterListener();
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Name == "galaxy.writes.unconfirmed") l.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref unconfirmed, value));
|
||||
listener.Start();
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Leg3"));
|
||||
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
|
||||
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await writer.WriteAsync(
|
||||
[new WriteRequest(WriteRef, 7100.0f + i)], _ => SecurityClassification.FreeAccess, ct);
|
||||
Assert.Equal(0u, r[0].StatusCode); // provisional Good off the empty-statuses reply
|
||||
}
|
||||
|
||||
listener.Dispose();
|
||||
Assert.Equal(3, Interlocked.Read(ref unconfirmed));
|
||||
}
|
||||
|
||||
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
|
||||
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
|
||||
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live write-meter legs.");
|
||||
return (endpoint!, apiKey!);
|
||||
}
|
||||
|
||||
private static MxGatewayClientOptions BuildClientOptions(string endpoint, string apiKey) => new()
|
||||
{
|
||||
Endpoint = new Uri(endpoint, UriKind.Absolute),
|
||||
ApiKey = apiKey,
|
||||
UseTls = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
StreamTimeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
@@ -108,6 +108,62 @@ public sealed class StatusCodeMapTests
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
|
||||
}
|
||||
|
||||
// ===== 06/S-1 write-completion rows: detail is an MxStatus detail code, NOT a quality byte =====
|
||||
// The gateway (≥ 53f69cd) correlates the worker's OnWriteComplete onto the unary write reply as
|
||||
// statuses[0]. Those rows carry MxStatus detail codes (live-captured on windev: 1007 operational
|
||||
// refusal on a read-only attribute, 1008 security refusal on a plain-advised handle). The old
|
||||
// truncation `(byte)(Detail & 0xFF)` pushed 1007 → 239 and 1008 → 240 into the OPC DA Good band,
|
||||
// mapping a REFUSED write to Good — no node revert, silently-lost write reported committed.
|
||||
|
||||
/// <summary>A refused write (operational error, MxStatus detail code) must map to a Bad-family code.</summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_WriteRefusal_OperationalError_Detail1007_IsBad()
|
||||
{
|
||||
// Live shape: writeRaw to read-only TestMachine_002.Tagname.
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 1007, RawDetectedBy = 5 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
|
||||
}
|
||||
|
||||
/// <summary>A security-refused write must map to BadUserAccessDenied, not Good.</summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_WriteRefusal_SecurityError_Detail1008_IsUserAccessDenied()
|
||||
{
|
||||
// Live shape: writeRaw through a plain-advised (subscription) handle — no supervisory advise.
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 1008, RawDetectedBy = 5 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>Category-level routing for large details preserves communication/configuration specificity.</summary>
|
||||
[Theory]
|
||||
[InlineData(MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError
|
||||
[InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError
|
||||
[InlineData(MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad
|
||||
public void FromMxStatus_LargeDetail_RoutesByCategory(MxStatusCategory category, uint expected)
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = category, Detail = 2001 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality
|
||||
/// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to
|
||||
/// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 216 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
|
||||
[Fact]
|
||||
public void TopByteCategoryBits_StayWithinOpcUaConvention()
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SubscriptionRegistry.TryResolveItemHandle"/> — the forward
|
||||
/// fullRef → live item-handle lookup the Galaxy writer uses to skip a redundant
|
||||
/// AddItem round-trip when an already-subscribed tag is written.
|
||||
/// </summary>
|
||||
public sealed class SubscriptionRegistryHandleResolveTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that after registering a binding, TryResolveItemHandle returns the correct handle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_ThenResolve_ReturnsHandle()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that TryResolveItemHandle is case-insensitive on the full reference.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Register_ThenResolve_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.TryResolveItemHandle("tag.a").ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a full reference that was never registered resolves to null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NeverRegistered_ReturnsNull()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
|
||||
registry.TryResolveItemHandle("Tag.NotHere").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after Remove(), the forward lookup returns null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_ThenResolve_ReturnsNull()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.Remove(1);
|
||||
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after Rebind() the forward lookup returns the new handle, not the old one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebind_ThenResolve_ReturnsNewHandle()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.Rebind(1, [new TagBinding("Tag.A", 99)]);
|
||||
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBe(99);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a binding with ItemHandle <= 0 (gateway-rejected) is not resolvable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FailedBinding_ZeroHandle_IsNotResolvable()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.Failed", 0)]);
|
||||
|
||||
registry.TryResolveItemHandle("Tag.Failed").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a binding with a negative ItemHandle is not resolvable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FailedBinding_NegativeHandle_IsNotResolvable()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.Failed", -1)]);
|
||||
|
||||
registry.TryResolveItemHandle("Tag.Failed").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the liveness guard: after the only subscriber of a handle is removed,
|
||||
/// the forward lookup returns null even if a stale forward-map entry lingers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_OnlySubscriber_LivenessGuard_ReturnsNull()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.Remove(1);
|
||||
|
||||
// After removal the subscriber set for handle 5 is gone, so TryResolveItemHandle
|
||||
// must return null regardless of whether the forward entry was cleaned up.
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag may legitimately appear in multiple driver subscriptions (separate OPC UA monitored
|
||||
/// items on the same Galaxy attribute) — they share one gw item handle. Removing ONE of them
|
||||
/// must keep the tag resolvable while another subscription still binds it, so the writer keeps
|
||||
/// borrowing instead of falling back to AddItem.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_OneOfTwoSubscribersForSameRef_StillResolves()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
registry.Register(2, [new TagBinding("Tag.A", 5)]);
|
||||
|
||||
registry.Remove(1);
|
||||
|
||||
// sub2 still binds Tag.A -> 5, so the borrow must still be offered.
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authoritative resolution: if two DIFFERENT refs ever map to the same numeric handle and the
|
||||
/// subscription for one is removed, that ref must NOT resolve to the handle that now belongs to
|
||||
/// the other ref — a wrong-tag write would be the worst outcome. Resolution confirms a live
|
||||
/// subscription genuinely binds fullRef -> handle, not just that the handle is alive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Remove_CrossRefSameHandle_DoesNotResolveToTheOtherRefsHandle()
|
||||
{
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tag.A", 5)]);
|
||||
registry.Register(2, [new TagBinding("Tag.B", 5)]);
|
||||
|
||||
registry.Remove(1);
|
||||
|
||||
// Tag.A's subscription is gone; handle 5 is still alive but now only Tag.B binds it.
|
||||
registry.TryResolveItemHandle("Tag.A").ShouldBeNull();
|
||||
registry.TryResolveItemHandle("Tag.B").ShouldBe(5);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user