Merge branch 'worktree-agent-adfb71e38279b8f48' into feat/scripted-alarm-shelve-routing

This commit is contained in:
Joseph Doherty
2026-05-22 10:22:56 -04:00
5 changed files with 296 additions and 75 deletions

View File

@@ -0,0 +1,112 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
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;
/// <summary>
/// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate)
/// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate).
/// </summary>
public sealed class GalaxyDriverInfrastructureTests
{
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("InfraTest"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions());
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
[Fact]
public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
driver.GetMemoryFootprint().ShouldBe(0L);
}
[Fact]
public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
{
// When subscriptions are active the footprint estimate must be > 0 so the
// server's memory-pressure mechanism sees the Galaxy driver as a participant.
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
}
[Fact]
public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
{
var sub = new NoOpSubscriber();
using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
var handle = await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None);
var afterSubscribe = driver.GetMemoryFootprint();
afterSubscribe.ShouldBeGreaterThan(0L);
await driver.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsubscribe = driver.GetMemoryFootprint();
afterUnsubscribe.ShouldBeLessThan(afterSubscribe,
"footprint must decrease when subscriptions are removed");
}
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
[Fact]
public async Task Dispose_SetsDisposedFlag_BlockingFurtherCapabilityCalls()
{
var sub = new NoOpSubscriber();
var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
driver.Dispose();
// Capability entry points all check ObjectDisposedException.ThrowIf — SubscribeAsync
// is representative and is guarded on line 1 of its body.
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None));
}
[Fact]
public async Task DisposeAsync_CanBeAwaitedWithoutDeadlock()
{
var sub = new NoOpSubscriber();
var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub);
// IAsyncDisposable.DisposeAsync must not block or throw.
await Should.NotThrowAsync(async () => await driver.DisposeAsync());
}
// ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls =====
private sealed class NoOpSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
var results = fullReferences.Select((r, i) => new SubscribeResult
{
TagAddress = r,
ItemHandle = i + 1,
WasSuccessful = true,
}).ToList();
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
}

View File

@@ -2,6 +2,7 @@ using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
// MxStatusCategory needed for Driver.Galaxy-003 regression tests.
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
@@ -46,6 +47,12 @@ public sealed class StatusCodeMapTests
else mapped.ShouldBe(StatusCodeMap.Bad);
}
// ===== Driver.Galaxy-003 regression: FromMxStatus uses IsSuccess() (category + success) =====
// The proto doc: "clients should branch on category, not on a specific success value."
// IsSuccess() requires BOTH success != 0 AND category == Ok — checking success alone
// would invert the mapping when the worker sets success=1 for an error code (the numeric
// value is NOT a boolean).
[Fact]
public void FromMxStatus_NullStatus_IsGood()
{
@@ -53,16 +60,26 @@ public sealed class StatusCodeMapTests
}
[Fact]
public void FromMxStatus_SuccessNonZero_IsGood()
public void FromMxStatus_SuccessNonZeroAndCategoryOk_IsGood()
{
var s = new MxStatusProxy { Success = 1 };
// Both conditions required — this is the canonical OK path.
var s = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Good);
}
[Fact]
public void FromMxStatus_SuccessZero_DetailKnown_MapsToSpecificCode()
public void FromMxStatus_SuccessNonZeroButCategoryNotOk_IsNotGood()
{
var s = new MxStatusProxy { Success = 0, Detail = 8 /* Bad_NotConnected */ };
// Bug fixed by Driver.Galaxy-003: success != 0 alone used to return Good even when
// category indicates an error. The proto says success is NOT a boolean — category wins.
var s = new MxStatusProxy { Success = 1, Category = MxStatusCategory.CommunicationError };
StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good);
}
[Fact]
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 /* Bad_NotConnected */ };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
}
@@ -95,4 +112,29 @@ public sealed class StatusCodeMapTests
((StatusCodeMap.BadNotConnected >> 30) & 0x3u).ShouldBe(2u);
((StatusCodeMap.BadOutOfService >> 30) & 0x3u).ShouldBe(2u);
}
// ===== Driver.Galaxy-004 regression: ToQualityCategoryByte lives next to its inverse =====
[Theory]
[InlineData(0x00000000u, (byte)192)] // Good
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
[InlineData(0x40000000u, (byte)64)] // Uncertain
[InlineData(0x408F0000u, (byte)64)] // UncertainSubNormal — still Uncertain category
[InlineData(0x80000000u, (byte)0)] // Bad
[InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category
[InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category
public void ToQualityCategoryByte_ExtractsTopTwoBitsAsOpcDaByte(uint statusCode, byte expected)
{
StatusCodeMap.ToQualityCategoryByte(statusCode).ShouldBe(expected);
}
[Fact]
public void ToQualityCategoryByte_IsRightInverseOfFromQualityByte_ForCategoryBytes()
{
// The category bytes the probe watcher uses are 0, 64, 192. Round-trip: a value
// that came FROM those bytes should map back to the same byte.
StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(0)).ShouldBe((byte)0);
StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(64)).ShouldBe((byte)64);
StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(192)).ShouldBe((byte)192);
}
}