rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx

Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Sta;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Verifies that the four new alarm <see cref="MxCommandKind"/> values
/// route through <see cref="MxAccessCommandExecutor"/> to a fake
/// <see cref="IAlarmCommandHandler"/> and that the resulting
/// <see cref="MxCommandReply"/> carries the expected payload.
///
/// The data-side <see cref="MxAccessSession"/> is constructed via a
/// no-op factory because the executor only touches it for non-alarm
/// command kinds — alarm dispatch never reaches the data session.
/// </summary>
public sealed class AlarmCommandExecutorTests
{
private const string SessionId = "S";
private const string CorrelationId = "C";
[Fact]
public void SubscribeAlarms_WithHandler_RoutesToHandlerAndReturnsOk()
{
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
Assert.Equal(SessionId, handler.LastSessionId);
}
[Fact]
public void SubscribeAlarms_WithoutHandler_ReturnsInvalidRequest()
{
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void SubscribeAlarms_WithEmptyExpression_ReturnsInvalidRequest()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = " ",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid();
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = g.ToString(),
Comment = "ack",
OperatorUser = "alice",
OperatorNode = "WS",
OperatorDomain = "CORP",
OperatorFullName = "Alice S",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(0, reply.Hresult);
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid);
Assert.Equal("alice", handler.LastAckOperatorName);
}
[Fact]
public void AcknowledgeAlarm_WithInvalidGuid_ReturnsInvalidRequest()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = "not-a-guid",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(-123, reply.Hresult);
Assert.Contains("-123", reply.DiagnosticMessage);
}
[Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
Comment = "ack",
OperatorUser = "alice",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.NotNull(handler.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
Assert.Equal("alice", handler.LastAckOperatorName);
}
[Fact]
public void AcknowledgeAlarmByName_WithEmptyName_ReturnsInvalidRequest()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = " ",
ProviderName = "Galaxy",
GroupName = "TestArea",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void QueryActiveAlarms_WithHandler_ReturnsPayloadWithSnapshots()
{
FakeAlarmHandler handler = new FakeAlarmHandler
{
QueryResult = new[]
{
new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" },
new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T2" },
},
};
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.QueryActiveAlarms,
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand
{
AlarmFilterPrefix = "Galaxy!A",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.NotNull(reply.QueryActiveAlarms);
Assert.Equal(2, reply.QueryActiveAlarms.Snapshots.Count);
Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
}
[Fact]
public void UnsubscribeAlarms_WithHandler_RoutesToHandler()
{
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.UnsubscribeAlarms,
UnsubscribeAlarms = new UnsubscribeAlarmsCommand(),
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(handler.UnsubscribeCalled);
}
[Fact]
public void UnsubscribeAlarms_WithoutHandler_IsOkNoop()
{
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.UnsubscribeAlarms,
UnsubscribeAlarms = new UnsubscribeAlarmsCommand(),
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
[Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Contains("simulated", reply.DiagnosticMessage);
}
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
{
// Construct an executor with a no-op data session — we only exercise
// the alarm switch arms, which never touch the data session. The
// session is built through the internal MxAccessSession.CreateForTesting
// factory (exposed via [assembly: InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")]
// on ZB.MOM.WW.MxGateway.Worker), so no reflection is needed.
return new MxAccessCommandExecutor(
session: MxAccessSession.CreateForTesting(
mxAccessServer: new NoopMxAccessServer(),
eventSink: new NoopEventSink()),
variantConverter: new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter(),
alarmCommandHandler: alarmHandler);
}
private sealed class FakeAlarmHandler : IAlarmCommandHandler
{
public string? LastSubscription { get; private set; }
public string? LastSessionId { get; private set; }
public bool UnsubscribeCalled { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckOperatorName { get; private set; }
public int AcknowledgeReturn { get; set; }
public bool AcknowledgeThrow { get; set; }
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
Array.Empty<ActiveAlarmSnapshot>();
public string? LastFilterPrefix { get; private set; }
public void Subscribe(string subscription, string sessionId)
{
LastSubscription = subscription;
LastSessionId = sessionId;
}
public void Unsubscribe()
{
UnsubscribeCalled = true;
}
public int Acknowledge(
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = operatorUser;
if (AcknowledgeThrow)
{
throw new InvalidOperationException("simulated alarm-handler failure");
}
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = operatorUser;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
{
LastFilterPrefix = alarmFilterPrefix;
return QueryResult;
}
public int PollCount { get; private set; }
public void PollOnce()
{
PollCount++;
}
public void Dispose() { }
}
}
@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for the per-session alarm command router. Uses a fake
/// consumer factory so the lazy-construction lifecycle on
/// <c>SubscribeAlarms</c> is exercised without touching wnwrap COM.
/// </summary>
public sealed class AlarmCommandHandlerTests
{
[Fact]
public void Subscribe_WhenNotYetSubscribed_CreatesConsumerAndCallsSubscribe()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!Area", "session-1");
Assert.True(handler.IsSubscribed);
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
}
[Fact]
public void Subscribe_WhenAlreadySubscribed_Throws()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!B", "s1"));
}
/// <summary>
/// Worker.Tests-024: pins both the disposal contract and the
/// origin of the propagated exception. The fake throws
/// <c>InvalidOperationException("simulated wnwrap subscribe failure")</c>
/// from <c>Subscribe</c>; the handler must propagate that exact
/// exception (not swallow it and rethrow its own) and dispose the
/// just-constructed consumer so a retry can build a fresh one.
/// Pinning the message guards against a regression where the
/// handler throws a different <see cref="InvalidOperationException"/>
/// (for example its own "already subscribed" guard) and the
/// disposal assertion alone would still pass while hiding the
/// real swallow.
/// </summary>
[Fact]
public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer()
{
FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true };
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
Assert.Contains("simulated wnwrap subscribe failure", exception.Message);
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
}
[Fact]
public void Unsubscribe_WhenSubscribed_DisposesConsumerAndClearsState()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Unsubscribe();
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
}
[Fact]
public void Unsubscribe_WithoutPriorSubscribe_IsNoop()
{
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer());
handler.Unsubscribe(); // Should not throw.
Assert.False(handler.IsSubscribed);
}
[Fact]
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
{
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
Assert.Equal(0, rc);
Assert.Equal(g, consumer.LastAckGuid);
Assert.Equal("u", consumer.LastAckOperatorName);
}
[Fact]
public void Acknowledge_BeforeSubscribe_ThrowsInvalidOperation()
{
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer());
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
}
[Fact]
public void QueryActive_WhenConsumerHasAlarms_ReturnsMappedProtoSnapshots()
{
FakeConsumer consumer = new FakeConsumer
{
SnapshotResult = new[]
{
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag1",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
},
},
};
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null);
Assert.Single(snapshots);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
}
[Fact]
public void QueryActive_WithPrefix_FiltersByPrefix()
{
FakeConsumer consumer = new FakeConsumer
{
SnapshotResult = new[]
{
NewRecord("Galaxy", "AreaA", "Tag1"),
NewRecord("Galaxy", "AreaB", "Tag2"),
},
};
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA");
Assert.Single(filtered);
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
}
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesAndDisposesConsumer()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Dispose();
Assert.True(consumer.Disposed);
Assert.Throws<ObjectDisposedException>(
() => handler.Subscribe("x", "y"));
}
/// <summary>
/// Worker-024 regression: every method that touches the underlying
/// <see cref="IMxAccessAlarmConsumer"/> must invoke the configured
/// STA-affinity guard. A guard that throws (simulating an off-STA
/// call) must propagate from every command-path entry point.
/// </summary>
[Fact]
public void EveryCommandPathEntry_InvokesThreadAffinityGuard()
{
FakeConsumer consumer = new FakeConsumer();
int guardInvocations = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer,
() => guardInvocations++);
// Subscribe is the first call — guard must run before the consumer
// factory is invoked. We tally invocation counts after each call so
// that a missed guard surfaces as the diagnostic count, not a generic
// "Subscribe should have failed".
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
Assert.Equal(1, guardInvocations);
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
Assert.Equal(2, guardInvocations);
handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
Assert.Equal(3, guardInvocations);
_ = handler.QueryActive(null);
Assert.Equal(4, guardInvocations);
handler.PollOnce();
Assert.Equal(5, guardInvocations);
handler.Unsubscribe();
Assert.Equal(6, guardInvocations);
}
/// <summary>
/// Worker-024 regression: a guard that throws must propagate from
/// every command-path entry point — proving the guard is not
/// swallowed by an inner try/catch.
/// </summary>
[Fact]
public void EveryCommandPathEntry_PropagatesAffinityGuardException()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer,
threadAffinityCheck: () =>
throw new InvalidOperationException("off-STA"));
// Subscribe: guard runs before the dispatcher is constructed.
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
// To exercise the other entry points we need a subscribed handler.
// Construct a parallel handler with a passing guard, then swap in a
// throwing one — but the existing handler is the simpler vehicle:
// re-build the handler with the guard initially silent, subscribe,
// then verify each remaining entry by passing a guard that throws
// through a second handler instance — actually the cleaner way is to
// assert each independently with a fresh handler. Below we reuse
// the same throwing handler for the not-subscribed-yet entries:
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
Assert.Throws<InvalidOperationException>(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null));
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
Assert.Throws<InvalidOperationException>(() => handler.Unsubscribe());
}
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{
return new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = provider,
Group = group,
TagName = tag,
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
};
}
private sealed class FakeConsumer : IMxAccessAlarmConsumer
{
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
#pragma warning restore CS0067
public string? LastSubscription { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckOperatorName { get; private set; }
public int AcknowledgeReturn { get; set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
public bool ThrowOnSubscribe { get; set; }
public bool Disposed { get; private set; }
public void Subscribe(string subscription)
{
LastSubscription = subscription;
if (ThrowOnSubscribe)
{
throw new InvalidOperationException("simulated wnwrap subscribe failure");
}
}
public int AcknowledgeByGuid(
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
public int PollCount { get; private set; }
public void PollOnce()
{
PollCount++;
}
public void Dispose()
{
Disposed = true;
}
}
}
@@ -0,0 +1,333 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for the in-process A.3 dispatcher: prove that
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> events
/// fan out to the worker's <see cref="MxAccessEventQueue"/> as proto
/// <see cref="OnAlarmTransitionEvent"/> messages with correctly mapped
/// fields. The fake consumer below stands in for the wnwrap-backed
/// production implementation so this exercise needs no AVEVA install.
/// </summary>
public sealed class AlarmDispatcherTests
{
private const string SessionId = "session-001";
[Fact]
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.Unspecified,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "TestMachine_001.TestAlarm001",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
TransitionTimestampUtc = ts,
AlarmComment = "Test alarm #1",
},
});
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
Assert.NotNull(workerEvent);
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family);
Assert.Equal(SessionId, mxEvent.SessionId);
OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition;
Assert.NotNull(body);
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", body.AlarmFullReference);
Assert.Equal("TestMachine_001.TestAlarm001", body.SourceObjectReference);
Assert.Equal("DSC", body.AlarmTypeName);
Assert.Equal(AlarmTransitionKind.Raise, body.TransitionKind);
Assert.Equal(500, body.Severity);
Assert.Equal("Test alarm #1", body.OperatorComment);
Assert.Equal("TestArea", body.Category);
Assert.NotNull(body.TransitionTimestamp);
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
}
[Fact]
public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition()
{
// Mapper.MapTransition returns Unspecified when the state didn't
// change; the dispatcher should drop the event before queueing.
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.UnackAlm,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "X",
TagName = "Y",
State = MxAlarmStateKind.UnackAlm,
},
});
Assert.Equal(0, queue.Count);
}
[Theory]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
public void MapTransition_ForEachStatePair_FollowsStateTable(
MxAlarmStateKind previous,
MxAlarmStateKind current,
AlarmTransitionKind expected)
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = previous,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "G",
TagName = "T",
State = current,
},
});
Assert.Equal(1, queue.Count);
queue.TryDequeue(out WorkerEvent? evt);
Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind);
}
[Fact]
public void Subscribe_WhenInvoked_ForwardsToConsumer()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
}
[Fact]
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
consumer.AcknowledgeReturn = 0;
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal("Acked", consumer.LastAckComment);
Assert.Equal("alice", consumer.LastAckOperatorName);
Assert.Equal("WS01", consumer.LastAckOperatorNode);
Assert.Equal("CORP", consumer.LastAckOperatorDomain);
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
}
[Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
int rc = dispatcher.AcknowledgeByName(
alarmName: "TestMachine_001.TestAlarm001",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "ack",
ackOperatorName: "alice",
ackOperatorNode: "WS",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc);
Assert.NotNull(consumer.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
}
[Fact]
public void SnapshotActiveAlarms_WhenConsumerHasRecords_MapsRecordsToProtos()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.SnapshotResult = new[]
{
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag1",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
TransitionTimestampUtc = ts,
AlarmComment = "x",
},
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag2",
Type = "ANL",
Priority = 100,
State = MxAlarmStateKind.AckAlm,
TransitionTimestampUtc = ts,
},
};
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms();
Assert.Equal(2, snapshots.Count);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
Assert.Equal(500, snapshots[0].Severity);
Assert.Equal(ts, snapshots[0].LastTransitionTimestamp.ToDateTime());
Assert.Equal("Galaxy!TestArea.Tag2", snapshots[1].AlarmFullReference);
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
}
[Fact]
public void Dispose_WhenSubscribed_UnsubscribesHandlerAndDisposesConsumer()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
dispatcher.Dispose();
Assert.True(consumer.Disposed);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.Unspecified,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "G",
TagName = "T",
State = MxAlarmStateKind.UnackAlm,
},
});
Assert.Equal(0, queue.Count);
}
private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer
{
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
public string? LastSubscription { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckComment { get; private set; }
public string? LastAckOperatorName { get; private set; }
public string? LastAckOperatorNode { get; private set; }
public string? LastAckOperatorDomain { get; private set; }
public string? LastAckOperatorFullName { get; private set; }
public int AcknowledgeReturn { get; set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
public bool Disposed { get; private set; }
public void RaiseTransition(MxAlarmTransitionEvent transition)
{
AlarmTransitionEmitted?.Invoke(this, transition);
}
public void Subscribe(string subscription)
{
LastSubscription = subscription;
}
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
LastAckGuid = alarmGuid;
LastAckComment = ackComment;
LastAckOperatorName = ackOperatorName;
LastAckOperatorNode = ackOperatorNode;
LastAckOperatorDomain = ackOperatorDomain;
LastAckOperatorFullName = ackOperatorFullName;
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
return SnapshotResult;
}
public int PollCount { get; private set; }
public void PollOnce()
{
PollCount++;
}
public void Dispose()
{
Disposed = true;
}
}
}
@@ -0,0 +1,122 @@
using System;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML
/// payloads into proto-friendly fields. The COM-side I/O on
/// <see cref="WnWrapAlarmConsumer"/> needs an AVEVA install and is
/// covered by the Skip-gated probe (<c>WnWrapConsumerProbeTests</c>);
/// these unit tests cover everything that doesn't touch the live COM
/// surface.
/// </summary>
public sealed class AlarmRecordTransitionMapperTests
{
[Fact]
public void ComposeFullReference_WithProviderAndGroup_UsesProviderBangGroupDotNameFormat()
{
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
providerName: "GalaxyAlarmProvider",
groupName: "Tank01",
alarmName: "Level.HiHi");
Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference);
}
[Fact]
public void ComposeFullReference_WithEmptyProvider_DropsProvider()
{
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
providerName: null, groupName: "Tank01", alarmName: "Level.HiHi");
Assert.Equal("Tank01.Level.HiHi", reference);
}
[Fact]
public void ComposeFullReference_WithEmptyGroup_DropsGroup()
{
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
providerName: "GalaxyAlarmProvider", groupName: null, alarmName: "GlobalAlarm");
Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference);
}
[Fact]
public void ComposeFullReference_WithEmptyProviderAndGroup_ReturnsAlarmName()
{
string reference = AlarmRecordTransitionMapper.ComposeFullReference(
providerName: null, groupName: null, alarmName: "Bare");
Assert.Equal("Bare", reference);
}
[Theory]
[InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)]
[InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)]
[InlineData("UNACK_RTN", MxAlarmStateKind.UnackRtn)]
[InlineData("ACK_RTN", MxAlarmStateKind.AckRtn)]
[InlineData("unack_alm", MxAlarmStateKind.UnackAlm)] // case-insensitive
[InlineData(" ACK_ALM ", MxAlarmStateKind.AckAlm)] // trim
[InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)]
[InlineData("", MxAlarmStateKind.Unspecified)]
[InlineData(null, MxAlarmStateKind.Unspecified)]
public void ParseStateKind_ForEachStateString_DecodesStateKind(string? input, MxAlarmStateKind expected)
{
Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input));
}
[Theory]
// First sighting: new alarm in *_ALM → Raise.
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Raise)]
// First sighting in *_RTN → Clear (unusual; missed the original raise).
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
// Active → Cleared.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.AckAlm, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
// Cleared → Active (re-trigger).
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.AckRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
// Unacked → Acked (operator ack).
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Acknowledge)]
// No-op (state unchanged) — caller is supposed to filter these out.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)]
// Current=Unspecified → Unspecified.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)]
public void MapTransition_ForEachStatePair_DecidesProtoKind(
MxAlarmStateKind previous,
MxAlarmStateKind current,
AlarmTransitionKind expected)
{
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
}
[Fact]
public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc()
{
// Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0.
// Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC.
DateTime utc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
"2026/5/1", "13:26:14.709", gmtOffsetMinutes: 240, dstAdjustMinutes: 0);
Assert.Equal(DateTimeKind.Utc, utc.Kind);
Assert.Equal(2026, utc.Year);
Assert.Equal(5, utc.Month);
Assert.Equal(1, utc.Day);
Assert.Equal(17, utc.Hour);
Assert.Equal(26, utc.Minute);
Assert.Equal(14, utc.Second);
Assert.Equal(709, utc.Millisecond);
}
[Fact]
public void ParseTransitionTimestampUtc_WithUnparseableInputs_ReturnsMinValue()
{
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0));
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("not a date", "13:00:00", 0, 0));
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("2026/5/1", "not a time", 0, 0));
}
}
@@ -0,0 +1,214 @@
using System;
using ArchestrA.MxAccess;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ComMxDataType = ArchestrA.MxAccess.MxDataType;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Integrated tests for <see cref="MxAccessBaseEventSink"/>: drive an MXAccess COM
/// event through the real sink → <see cref="MxAccessEventMapper"/> →
/// <see cref="MxAccessEventQueue"/> pipeline and assert a correctly-converted
/// protobuf <see cref="WorkerEvent"/> lands in the queue.
/// </summary>
/// <remarks>
/// Boundary: the COM-side <c>+=</c> subscription performed in
/// <see cref="MxAccessBaseEventSink.Attach"/> casts the supplied object to the
/// sealed <c>LMXProxyServerClass</c> RCW and cannot run without a live MXAccess COM
/// object, so <c>Attach</c>/<c>Detach</c> are not exercised here. The event
/// handlers themselves (<c>OnDataChange</c>, <c>OnWriteComplete</c>,
/// <c>OperationComplete</c>, <c>OnBufferedDataChange</c>) are the exact delegate
/// targets the COM runtime invokes; calling them directly reproduces an STA-thread
/// COM callback and exercises the genuine conversion + enqueue path. The
/// <c>sessionId</c> normally set by <c>Attach</c> defaults to empty here, which the
/// assertions account for. The COM-event-conversion fault branch is left to
/// <see cref="MxAccessEventMapperTests"/> and the queue's own fault tests.
/// </remarks>
public sealed class MxAccessBaseEventSinkTests
{
/// <summary>
/// Verifies that an OnDataChange COM callback converts to a protobuf event and lands in the queue.
/// </summary>
[Fact]
public void OnDataChange_ComCallback_ConvertedEventLandsInQueue()
{
MxAccessEventQueue queue = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper());
DateTime timestamp = new(2026, 5, 18, 9, 15, 0, DateTimeKind.Utc);
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OnDataChange(
hLMXServerHandle: 7,
phItemHandle: 21,
pvItemValue: 1234,
pwItemQuality: 192,
pftItemTimeStamp: timestamp,
ref statuses);
Assert.Equal(1, queue.Count);
Assert.Equal(1UL, queue.LastEventSequence);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
Assert.NotNull(workerEvent);
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family);
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase);
Assert.Equal(7, mxEvent.ServerHandle);
Assert.Equal(21, mxEvent.ItemHandle);
Assert.Equal(1234, mxEvent.Value.Int32Value);
Assert.Equal(192, mxEvent.Quality);
Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime());
Assert.Equal(1UL, mxEvent.WorkerSequence);
Assert.NotNull(mxEvent.WorkerTimestamp);
}
/// <summary>
/// Verifies that an OnDataChange COM callback also writes the value into the
/// per-session value cache, so a later <c>ReadBulk</c> on an already-advised
/// tag can serve the cached value without re-advising. The cache update must
/// fire after the event has cleared the outbound queue — verified here by
/// checking the cache only after the queue confirms the enqueue succeeded.
/// </summary>
[Fact]
public void OnDataChange_ComCallback_PopulatesValueCache()
{
MxAccessEventQueue queue = new();
MxAccessValueCache cache = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache);
DateTime timestamp = new(2026, 5, 18, 9, 15, 0, DateTimeKind.Utc);
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OnDataChange(
hLMXServerHandle: 7,
phItemHandle: 21,
pvItemValue: 1234,
pwItemQuality: 192,
pftItemTimeStamp: timestamp,
ref statuses);
Assert.Equal(1, queue.Count);
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached));
Assert.Equal(1UL, cached.Version);
Assert.Equal(1234, cached.Value.Int32Value);
Assert.Equal(192, cached.Quality);
Assert.Equal(timestamp, cached.SourceTimestamp.ToDateTime());
}
/// <summary>
/// Verifies that the sink-bound <c>ValueCache</c> is exposed for sharing with
/// the owning <see cref="MxAccessSession"/> so writes and reads see the same
/// instance.
/// </summary>
[Fact]
public void ValueCache_ReturnsTheInstanceBoundAtConstruction()
{
MxAccessEventQueue queue = new();
MxAccessValueCache cache = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache);
Assert.Same(cache, sink.ValueCache);
}
/// <summary>
/// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences.
/// </summary>
[Fact]
public void OnDataChange_MultipleComCallbacks_QueueAssignsMonotonicSequences()
{
MxAccessEventQueue queue = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper());
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OnDataChange(1, 10, 100, 192, DateTime.UtcNow, ref statuses);
sink.OnDataChange(1, 11, 200, 192, DateTime.UtcNow, ref statuses);
sink.OnDataChange(1, 12, 300, 192, DateTime.UtcNow, ref statuses);
Assert.Equal(3, queue.Count);
Assert.Equal(3UL, queue.LastEventSequence);
Assert.True(queue.TryDequeue(out WorkerEvent? first));
Assert.True(queue.TryDequeue(out WorkerEvent? second));
Assert.True(queue.TryDequeue(out WorkerEvent? third));
Assert.Equal(1UL, first!.Event.WorkerSequence);
Assert.Equal(2UL, second!.Event.WorkerSequence);
Assert.Equal(3UL, third!.Event.WorkerSequence);
Assert.Equal(10, first.Event.ItemHandle);
Assert.Equal(12, third.Event.ItemHandle);
}
/// <summary>
/// Verifies that an OnWriteComplete COM callback lands in the queue with the correct family.
/// </summary>
[Fact]
public void OnWriteComplete_ComCallback_ConvertedEventLandsInQueue()
{
MxAccessEventQueue queue = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper());
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OnWriteComplete(hLMXServerHandle: 3, phItemHandle: 9, ref statuses);
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, mxEvent.BodyCase);
Assert.Equal(3, mxEvent.ServerHandle);
Assert.Equal(9, mxEvent.ItemHandle);
Assert.Equal(1UL, mxEvent.WorkerSequence);
}
/// <summary>
/// Verifies that an OperationComplete COM callback lands in the queue with the correct family.
/// </summary>
[Fact]
public void OperationComplete_ComCallback_ConvertedEventLandsInQueue()
{
MxAccessEventQueue queue = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper());
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
sink.OperationComplete(hLMXServerHandle: 4, phItemHandle: 8, ref statuses);
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OperationComplete, mxEvent.Family);
Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, mxEvent.BodyCase);
Assert.Equal(4, mxEvent.ServerHandle);
Assert.Equal(8, mxEvent.ItemHandle);
}
/// <summary>
/// Verifies that an OnBufferedDataChange COM callback converts the value and lands in the queue.
/// </summary>
[Fact]
public void OnBufferedDataChange_ComCallback_ConvertedEventLandsInQueue()
{
MxAccessEventQueue queue = new();
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper());
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
// Raw MXAccess data-type code 2 == Integer (see MxAccessEventMapper.MapMxDataType).
const int integerDataTypeCode = 2;
sink.OnBufferedDataChange(
hLMXServerHandle: 5,
phItemHandle: 13,
dtDataType: (ComMxDataType)integerDataTypeCode,
pvItemValue: 77,
pwItemQuality: 192,
pftItemTimeStamp: DateTime.UtcNow,
ref statuses);
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family);
Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, mxEvent.BodyCase);
Assert.Equal(5, mxEvent.ServerHandle);
Assert.Equal(13, mxEvent.ItemHandle);
Assert.Equal(integerDataTypeCode, mxEvent.OnBufferedDataChange.RawDataType);
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Worker-007 regression tests for <see cref="MxAccessComServer"/>. The
/// adapter no longer falls back to late-bound <c>Type.InvokeMember</c>
/// reflection: a COM object must implement either the typed
/// <c>ILMXProxyServer</c> COM interface family (production) or
/// <see cref="IMxAccessServer"/> directly (test fakes).
/// </summary>
public sealed class MxAccessComServerTests
{
/// <summary>
/// A COM object implementing <see cref="IMxAccessServer"/> is routed
/// through the typed interface — no reflection — preserving arguments
/// and return values.
/// </summary>
[Fact]
public void Methods_WithTypedServer_RouteThroughTypedInterface()
{
RecordingMxAccessServer typed = new(registerHandle: 77);
MxAccessComServer adapter = new(typed);
int serverHandle = adapter.Register("client-a");
adapter.Advise(serverHandle, itemHandle: 9);
adapter.Unregister(serverHandle);
Assert.Equal(77, serverHandle);
Assert.Equal("client-a", typed.RegisteredClientName);
Assert.Equal(new[] { "Register:client-a", "Advise:77:9", "Unregister:77" }, typed.Calls);
}
/// <summary>
/// A COM object that implements neither the typed COM interface family
/// nor <see cref="IMxAccessServer"/> fails fast with a clear
/// <see cref="InvalidOperationException"/> instead of a late-bound
/// reflection call.
/// </summary>
[Fact]
public void Methods_WithUntypedObject_ThrowInvalidOperation()
{
MxAccessComServer adapter = new(new object());
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Contains("does not implement", exception.Message, StringComparison.Ordinal);
Assert.Contains(nameof(IMxAccessServer), exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Exceptions thrown by the typed server propagate unchanged — no
/// <c>TargetInvocationException</c> wrapping (reflection is gone).
/// </summary>
[Fact]
public void Methods_WhenTypedServerThrows_PropagateOriginalException()
{
RecordingMxAccessServer typed = new(registerHandle: 1)
{
ThrowOnRegister = new InvalidOperationException("register failed"),
};
MxAccessComServer adapter = new(typed);
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Equal("register failed", exception.Message);
}
private sealed class RecordingMxAccessServer : IMxAccessServer
{
private readonly int registerHandle;
private readonly List<string> calls = new();
public RecordingMxAccessServer(int registerHandle)
{
this.registerHandle = registerHandle;
}
public string? RegisteredClientName { get; private set; }
public Exception? ThrowOnRegister { get; set; }
public IReadOnlyList<string> Calls => calls.ToArray();
public int Register(string clientName)
{
calls.Add($"Register:{clientName}");
RegisteredClientName = clientName;
if (ThrowOnRegister is not null)
{
throw ThrowOnRegister;
}
return registerHandle;
}
public void Unregister(int serverHandle)
{
calls.Add($"Unregister:{serverHandle}");
}
public int AddItem(int serverHandle, string itemDefinition)
{
calls.Add($"AddItem:{serverHandle}:{itemDefinition}");
return 0;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
{
calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
return 0;
}
public void RemoveItem(int serverHandle, int itemHandle)
{
calls.Add($"RemoveItem:{serverHandle}:{itemHandle}");
}
public void Advise(int serverHandle, int itemHandle)
{
calls.Add($"Advise:{serverHandle}:{itemHandle}");
}
public void UnAdvise(int serverHandle, int itemHandle)
{
calls.Add($"UnAdvise:{serverHandle}:{itemHandle}");
}
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
calls.Add($"Write:{serverHandle}:{itemHandle}:{value}:{userId}");
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
calls.Add($"Write2:{serverHandle}:{itemHandle}:{value}:{timestamp}:{userId}");
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
calls.Add($"WriteSecured:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}");
}
public void WriteSecured2(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
calls.Add($"WriteSecured2:{serverHandle}:{itemHandle}:{currentUserId}:{verifierUserId}:{value}:{timestamp}");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
using System;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class MxAccessEventMapperTests
{
private readonly MxAccessEventMapper mapper = new();
/// <summary>Verifies that creating an OnDataChange event converts value, timestamp, quality, and statuses.</summary>
[Fact]
public void CreateOnDataChange_ConvertsValueTimestampQualityAndStatuses()
{
DateTime timestamp = new(2026, 4, 26, 12, 30, 0, DateTimeKind.Utc);
FakeStatus[] statuses =
{
new()
{
success = -1,
category = 0,
detectedBy = 5,
detail = 0,
},
};
MxEvent mxEvent = mapper.CreateOnDataChange(
"session-1",
serverHandle: 12,
itemHandle: 34,
value: 42,
quality: 192,
timestamp: timestamp,
statuses: statuses);
Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family);
Assert.Equal("session-1", mxEvent.SessionId);
Assert.Equal(12, mxEvent.ServerHandle);
Assert.Equal(34, mxEvent.ItemHandle);
Assert.Equal(42, mxEvent.Value.Int32Value);
Assert.Equal(192, mxEvent.Quality);
Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime());
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase);
MxStatusProxy status = Assert.Single(mxEvent.Statuses);
Assert.Equal(-1, status.Success);
Assert.Equal(MxStatusCategory.Ok, status.Category);
Assert.Equal(MxStatusSource.RespondingAutomationObject, status.DetectedBy);
}
/// <summary>Verifies that OnWriteComplete and OperationComplete events preserve distinct families.</summary>
[Fact]
public void CreateOnWriteCompleteAndOperationComplete_PreservesDistinctFamilies()
{
MxEvent writeComplete = mapper.CreateOnWriteComplete(
"session-1",
serverHandle: 1,
itemHandle: 2,
statuses: Array.Empty<FakeStatus>());
MxEvent operationComplete = mapper.CreateOperationComplete(
"session-1",
serverHandle: 1,
itemHandle: 2,
statuses: Array.Empty<FakeStatus>());
Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family);
Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, writeComplete.BodyCase);
Assert.Equal(MxEventFamily.OperationComplete, operationComplete.Family);
Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, operationComplete.BodyCase);
}
/// <summary>Verifies that OnBufferedDataChange events preserve raw data type and array metadata.</summary>
[Fact]
public void CreateOnBufferedDataChange_PreservesRawDataTypeAndArrayMetadata()
{
DateTime firstTimestamp = new(2026, 4, 26, 13, 0, 0, DateTimeKind.Utc);
DateTime secondTimestamp = new(2026, 4, 26, 13, 1, 0, DateTimeKind.Utc);
MxEvent mxEvent = mapper.CreateOnBufferedDataChange(
"session-1",
serverHandle: 10,
itemHandle: 20,
rawDataType: 2,
value: new[] { 7, 8 },
quality: new[] { 192, 0 },
timestamp: new[] { firstTimestamp, secondTimestamp },
statuses: null);
Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family);
Assert.Equal(MxDataType.Integer, mxEvent.OnBufferedDataChange.DataType);
Assert.Equal(2, mxEvent.OnBufferedDataChange.RawDataType);
Assert.Equal(MxDataType.Integer, mxEvent.Value.ArrayValue.ElementDataType);
Assert.Equal(new[] { 7, 8 }, mxEvent.Value.ArrayValue.Int32Values.Values);
Assert.Equal(new[] { 192, 0 }, mxEvent.OnBufferedDataChange.QualityValues.Int32Values.Values);
Assert.Equal(2, mxEvent.OnBufferedDataChange.TimestampValues.TimestampValues.Values.Count);
}
/// <summary>Verifies that MapMxDataType maps raw MXAccess data types to protobuf enum values.</summary>
/// <param name="rawDataType">Raw MXAccess data type value to map.</param>
/// <param name="expectedDataType">Expected MxDataType enum value.</param>
[Theory]
[InlineData(-1, MxDataType.Unknown)]
[InlineData(0, MxDataType.NoData)]
[InlineData(1, MxDataType.Boolean)]
[InlineData(2, MxDataType.Integer)]
[InlineData(6, MxDataType.Time)]
[InlineData(15, MxDataType.InternationalizedString)]
[InlineData(999, MxDataType.Unknown)]
public void MapMxDataType_MapsInstalledMxAccessValues(
int rawDataType,
MxDataType expectedDataType)
{
Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType));
}
/// <summary>Verifies CreateOnAlarmTransition packs the full alarm payload.</summary>
[Fact]
public void CreateOnAlarmTransition_PopulatesFullPayload()
{
DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
DateTime ack = raise.AddSeconds(45);
MxEvent mxEvent = mapper.CreateOnAlarmTransition(
sessionId: "session-1",
alarmFullReference: "Tank01.Level.HiHi",
sourceObjectReference: "Tank01",
alarmTypeName: "AnalogLimitAlarm.HiHi",
transitionKind: AlarmTransitionKind.Acknowledge,
severity: 750,
originalRaiseTimestampUtc: raise,
transitionTimestampUtc: ack,
operatorUser: "alice",
operatorComment: "investigating",
category: "Process",
description: "Tank 01 high-high level",
statuses: null);
Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family);
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, mxEvent.BodyCase);
OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition;
Assert.Equal("Tank01.Level.HiHi", body.AlarmFullReference);
Assert.Equal("Tank01", body.SourceObjectReference);
Assert.Equal("AnalogLimitAlarm.HiHi", body.AlarmTypeName);
Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
Assert.Equal(750, body.Severity);
Assert.Equal(raise, body.OriginalRaiseTimestamp.ToDateTime());
Assert.Equal(ack, body.TransitionTimestamp.ToDateTime());
Assert.Equal("alice", body.OperatorUser);
Assert.Equal("investigating", body.OperatorComment);
Assert.Equal("Process", body.Category);
Assert.Equal("Tank 01 high-high level", body.Description);
}
/// <summary>Verifies CreateOnAlarmTransition handles a Raise transition with no operator metadata.</summary>
[Fact]
public void CreateOnAlarmTransition_RaiseTransitionLeavesOperatorFieldsEmpty()
{
DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
MxEvent mxEvent = mapper.CreateOnAlarmTransition(
sessionId: "session-1",
alarmFullReference: "Tank01.Level.HiHi",
sourceObjectReference: "Tank01",
alarmTypeName: "AnalogLimitAlarm.HiHi",
transitionKind: AlarmTransitionKind.Raise,
severity: 750,
originalRaiseTimestampUtc: null,
transitionTimestampUtc: raise,
operatorUser: string.Empty,
operatorComment: string.Empty,
category: "Process",
description: "Tank 01 high-high level",
statuses: null);
Assert.Equal(AlarmTransitionKind.Raise, mxEvent.OnAlarmTransition.TransitionKind);
Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorUser);
Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorComment);
Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp);
}
/// <summary>
/// Verifies that an OnDataChange whose timestamp arrives as the
/// VT_BSTR string MXAccess actually delivers still populates
/// <see cref="MxEvent.SourceTimestamp"/> — the string is parsed as
/// local time and converted to UTC.
/// </summary>
[Fact]
public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp()
{
// The exact shape MXAccess fires (see captures/003-subscribe-scalars).
const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM";
MxEvent mxEvent = mapper.CreateOnDataChange(
"session-1",
serverHandle: 1,
itemHandle: 1,
value: 99,
quality: 192,
timestamp: mxAccessTimestamp,
statuses: null);
Assert.NotNull(mxEvent.SourceTimestamp);
DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime());
}
/// <summary>
/// Verifies the MXAccess timestamp string is interpreted as the host's
/// local time and returned as UTC. Written timezone-independently by
/// round-tripping a local wall-clock time.
/// </summary>
[Fact]
public void TryParseSourceTimestamp_InterpretsStringAsLocalTime()
{
DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified);
string text = localWall.ToString(CultureInfo.CurrentCulture);
Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
Assert.Equal(DateTimeKind.Utc, utc.Kind);
DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
Assert.Equal(expectedUtc, utc);
}
/// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a timestamp")]
public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text)
{
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
}
private sealed class FakeStatus
{
public int success;
public int category;
public int detectedBy;
public int detail;
}
}
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class MxAccessEventQueueTests
{
/// <summary>Verifies that Enqueue assigns monotonic worker sequences and preserves event order.</summary>
[Fact]
public void Enqueue_AssignsMonotonicWorkerSequencesAndPreservesOrder()
{
MxAccessEventQueue queue = new(capacity: 4);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
queue.Enqueue(CreateEvent(MxEventFamily.OnWriteComplete, itemHandle: 11));
Assert.Equal(2, queue.Count);
Assert.Equal(2UL, queue.LastEventSequence);
Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedFirst));
Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedSecond));
Assert.Equal(1UL, dequeuedFirst?.Event.WorkerSequence);
Assert.Equal(2UL, dequeuedSecond?.Event.WorkerSequence);
Assert.NotNull(dequeuedFirst?.Event.WorkerTimestamp);
Assert.Equal(10, dequeuedFirst?.Event.ItemHandle);
Assert.Equal(11, dequeuedSecond?.Event.ItemHandle);
Assert.False(queue.TryDequeue(out _));
}
/// <summary>Verifies that Drain removes at most the requested number of events.</summary>
[Fact]
public void Drain_RemovesAtMostRequestedEvents()
{
MxAccessEventQueue queue = new(capacity: 4);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11));
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12));
IReadOnlyList<WorkerEvent> drained = queue.Drain(maxEvents: 2);
Assert.Equal(2, drained.Count);
Assert.Equal(10, drained[0].Event.ItemHandle);
Assert.Equal(11, drained[1].Event.ItemHandle);
Assert.Equal(1, queue.Count);
}
/// <summary>Verifies that Drain with maxEvents 0 drains every queued event.</summary>
[Fact]
public void Drain_WithZeroMaxEvents_DrainsAllEvents()
{
MxAccessEventQueue queue = new(capacity: 4);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11));
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12));
IReadOnlyList<WorkerEvent> drained = queue.Drain(maxEvents: 0);
Assert.Equal(3, drained.Count);
Assert.Equal(new[] { 10, 11, 12 }, new[]
{
drained[0].Event.ItemHandle,
drained[1].Event.ItemHandle,
drained[2].Event.ItemHandle,
});
Assert.Equal(0, queue.Count);
}
/// <summary>Verifies that draining an empty queue returns an empty list.</summary>
[Fact]
public void Drain_WhenQueueIsEmpty_ReturnsEmptyList()
{
MxAccessEventQueue queue = new(capacity: 4);
Assert.Empty(queue.Drain(maxEvents: 0));
Assert.Empty(queue.Drain(maxEvents: 5));
Assert.Equal(0, queue.Count);
}
/// <summary>Verifies that Enqueue is rejected after a fault is recorded manually.</summary>
[Fact]
public void Enqueue_AfterRecordFault_ThrowsInvalidOperationException()
{
MxAccessEventQueue queue = new(capacity: 4);
queue.RecordFault(new WorkerFault
{
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
});
Assert.Throws<InvalidOperationException>(
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10)));
Assert.Equal(0, queue.Count);
}
/// <summary>Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded.</summary>
[Fact]
public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents()
{
MxAccessEventQueue queue = new(capacity: 1);
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
MxAccessEventQueueOverflowException overflow = Assert.Throws<MxAccessEventQueueOverflowException>(
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11)));
Assert.Equal(1, overflow.Capacity);
Assert.True(queue.IsFaulted);
Assert.Equal(WorkerFaultCategory.QueueOverflow, queue.Fault?.Category);
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, queue.Fault?.ProtocolStatus.Code);
Assert.Throws<InvalidOperationException>(
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)));
}
/// <summary>Verifies that RecordFault keeps the first recorded fault.</summary>
[Fact]
public void RecordFault_KeepsFirstFault()
{
MxAccessEventQueue queue = new(capacity: 1);
queue.RecordFault(new WorkerFault
{
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
});
queue.RecordFault(new WorkerFault
{
Category = WorkerFaultCategory.QueueOverflow,
});
Assert.True(queue.IsFaulted);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, queue.Fault?.Category);
}
private static MxEvent CreateEvent(
MxEventFamily family,
int itemHandle)
{
MxEvent mxEvent = new()
{
Family = family,
SessionId = "session-1",
ServerHandle = 1,
ItemHandle = itemHandle,
};
switch (family)
{
case MxEventFamily.OnWriteComplete:
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
break;
default:
mxEvent.OnDataChange = new OnDataChangeEvent();
break;
}
return mxEvent;
}
}
@@ -0,0 +1,25 @@
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class MxAccessInteropInfoTests
{
/// <summary>Verifies that interop info identifies the correct MXAccess COM target.</summary>
[Fact]
public void InteropInfo_IdentifiesInstalledMxAccessComTarget()
{
Assert.Equal("LMXProxy.LMXProxyServer.1", MxAccessInteropInfo.ProgId);
Assert.Equal("LMXProxy.LMXProxyServer", MxAccessInteropInfo.VersionIndependentProgId);
Assert.Equal("{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", MxAccessInteropInfo.Clsid);
Assert.Equal("ArchestrA.MxAccess.LMXProxyServerClass", MxAccessInteropInfo.ComClassName);
}
/// <summary>Verifies that interop assembly name comes from referenced MXAccess assembly.</summary>
[Fact]
public void InteropAssemblyName_ComesFromReferencedMxAccessAssembly()
{
Assert.Equal("ArchestrA.MxAccess", MxAccessInteropInfo.InteropAssemblyName);
Assert.Equal(3, MxAccessInteropInfo.InteropAssemblyVersion.Major);
Assert.Equal(2, MxAccessInteropInfo.InteropAssemblyVersion.Minor);
}
}
@@ -0,0 +1,367 @@
using System;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Sta;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class MxAccessLiveComCreationTests
{
private const string LiveClientName = "ZB.MOM.WW.MxGateway.Worker.Tests";
private const string DefaultLiveAddItemReference = "TestChildObject.TestInt";
private const string DefaultLiveAddItem2Definition = "TestInt";
private const string DefaultLiveAddItem2Context = "TestChildObject";
/// <summary>Verifies that StartAsync creates the installed MXAccess COM object on the STA thread when opted in.</summary>
[LiveMxAccessFact]
public async Task StartAsync_WhenOptedIn_CreatesInstalledMxAccessComObjectOnSta()
{
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
}
/// <summary>Verifies that Register and Unregister round-trip server handles with installed MXAccess.</summary>
[LiveMxAccessFact]
public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle()
{
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
MxCommandReply registerReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-register",
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = LiveClientName,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
Assert.True(registerReply.Register.ServerHandle > 0);
MxCommandReply unregisterReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-unregister",
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = registerReply.Register.ServerHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code);
}
/// <summary>Verifies that AddItem and RemoveItem round-trip item handles with installed MXAccess.</summary>
[LiveMxAccessFact]
public async Task AddItemAndRemoveItem_WhenOptedIn_RoundTripsInstalledMxAccessItemHandle()
{
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-add-register");
int serverHandle = registerReply.Register.ServerHandle;
int itemHandle = 0;
try
{
MxCommandReply addItemReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-add-item",
new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = GetLiveAddItemReference(),
},
}));
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
Assert.True(addItemReply.AddItem.ItemHandle > 0);
itemHandle = addItemReply.AddItem.ItemHandle;
MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-remove-item",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code);
itemHandle = 0;
}
finally
{
if (itemHandle > 0)
{
await session.DispatchAsync(new StaCommand(
"session-1",
"live-remove-item-cleanup",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
}
await UnregisterLiveSessionAsync(session, serverHandle, "live-add-unregister");
}
}
/// <summary>Verifies that AddItem2 and RemoveItem preserve item context with installed MXAccess.</summary>
[LiveMxAccessFact]
public async Task AddItem2AndRemoveItem_WhenOptedIn_PreservesContextForInstalledMxAccess()
{
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-add2-register");
int serverHandle = registerReply.Register.ServerHandle;
int itemHandle = 0;
try
{
MxCommandReply addItem2Reply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-add-item2",
new MxCommand
{
Kind = MxCommandKind.AddItem2,
AddItem2 = new AddItem2Command
{
ServerHandle = serverHandle,
ItemDefinition = DefaultLiveAddItem2Definition,
ItemContext = DefaultLiveAddItem2Context,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, addItem2Reply.ProtocolStatus.Code);
Assert.True(addItem2Reply.AddItem2.ItemHandle > 0);
itemHandle = addItem2Reply.AddItem2.ItemHandle;
MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-remove-item2",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code);
itemHandle = 0;
}
finally
{
if (itemHandle > 0)
{
await session.DispatchAsync(new StaCommand(
"session-1",
"live-remove-item2-cleanup",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
}
await UnregisterLiveSessionAsync(session, serverHandle, "live-add2-unregister");
}
}
/// <summary>Verifies that Advise and UnAdvise round-trip subscriptions with installed MXAccess.</summary>
[LiveMxAccessFact]
public async Task AdviseAndUnAdvise_WhenOptedIn_RoundTripsInstalledMxAccessSubscription()
{
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-advise-register");
int serverHandle = registerReply.Register.ServerHandle;
int itemHandle = 0;
bool advised = false;
try
{
MxCommandReply addItemReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-advise-add-item",
new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = GetLiveAddItemReference(),
},
}));
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
Assert.True(addItemReply.AddItem.ItemHandle > 0);
itemHandle = addItemReply.AddItem.ItemHandle;
MxCommandReply adviseReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-advise",
new MxCommand
{
Kind = MxCommandKind.Advise,
Advise = new AdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
advised = true;
MxCommandReply unAdviseReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-unadvise",
new MxCommand
{
Kind = MxCommandKind.UnAdvise,
UnAdvise = new UnAdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, unAdviseReply.ProtocolStatus.Code);
advised = false;
MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-advise-remove-item",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code);
itemHandle = 0;
}
finally
{
if (advised && itemHandle > 0)
{
await session.DispatchAsync(new StaCommand(
"session-1",
"live-unadvise-cleanup",
new MxCommand
{
Kind = MxCommandKind.UnAdvise,
UnAdvise = new UnAdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
}
if (itemHandle > 0)
{
await session.DispatchAsync(new StaCommand(
"session-1",
"live-advise-remove-item-cleanup",
new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
}));
}
await UnregisterLiveSessionAsync(session, serverHandle, "live-advise-unregister");
}
}
private static string GetLiveAddItemReference()
{
string itemReference = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_ITEM");
return string.IsNullOrWhiteSpace(itemReference)
? DefaultLiveAddItemReference
: itemReference;
}
private static async Task<MxCommandReply> RegisterLiveSessionAsync(
MxAccessStaSession session,
string correlationId)
{
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = LiveClientName,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.Register.ServerHandle > 0);
return reply;
}
private static async Task UnregisterLiveSessionAsync(
MxAccessStaSession session,
int serverHandle,
string correlationId)
{
MxCommandReply unregisterReply = await session.DispatchAsync(new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = serverHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code);
}
}
@@ -0,0 +1,532 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Sta;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Tests for <see cref="MxAccessStaSession"/>.
/// </summary>
public sealed class MxAccessStaSessionTests
{
/// <summary>
/// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread.
/// </summary>
[Fact]
public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread()
{
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, eventSink);
WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234);
Assert.Equal(1234, ready.WorkerProcessId);
Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid);
Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid);
Assert.NotNull(ready.ReadyTimestamp);
Assert.Equal(runtime.StaThreadId, factory.CreateThreadId);
Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId);
Assert.Equal(ApartmentState.STA, factory.CreateApartmentState);
Assert.Same(factory.CreatedObject, eventSink.AttachedObject);
Assert.Equal("session-1", eventSink.SessionId);
}
/// <summary>
/// Verifies that StartAsync maps creation exceptions with HResult when the factory fails.
/// </summary>
[Fact]
public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult()
{
const int hresult = unchecked((int)0x80040154);
FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult));
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, eventSink);
MxAccessCreationException exception = await Assert.ThrowsAsync<MxAccessCreationException>(
() => session.StartAsync(workerProcessId: 1234));
Assert.Equal(hresult, exception.CapturedHResult);
Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId);
Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid);
Assert.Null(eventSink.AttachedObject);
}
/// <summary>
/// Verifies that Dispose detaches the event sink on the STA thread.
/// </summary>
[Fact]
public async Task Dispose_DetachesEventSinkOnStaThread()
{
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
MxAccessStaSession session = new(runtime, factory, eventSink);
await session.StartAsync(workerProcessId: 1234);
session.Dispose();
Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId);
}
private static StaRuntime CreateRuntime()
{
return new StaRuntime(
new NoopComApartmentInitializer(),
new StaMessagePump(),
TimeSpan.FromMilliseconds(25));
}
/// <summary>
/// Fake MXAccess COM object factory for testing.
/// </summary>
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
{
private readonly Exception? exception;
/// <summary>
/// Initializes a fake factory that optionally throws an exception.
/// </summary>
/// <param name="exception">Exception to throw when Create is called; null to succeed.</param>
public FakeMxAccessComObjectFactory(Exception? exception = null)
{
this.exception = exception;
}
/// <summary>
/// Gets the COM object created by this factory.
/// </summary>
public object CreatedObject { get; } = new();
/// <summary>
/// Gets the managed thread ID when Create was called.
/// </summary>
public int? CreateThreadId { get; private set; }
/// <summary>
/// Gets the apartment state when Create was called.
/// </summary>
public ApartmentState? CreateApartmentState { get; private set; }
/// <summary>
/// Creates the COM object or throws the configured exception.
/// </summary>
public object Create()
{
CreateThreadId = Thread.CurrentThread.ManagedThreadId;
CreateApartmentState = Thread.CurrentThread.GetApartmentState();
if (exception is not null)
{
throw exception;
}
return CreatedObject;
}
}
/// <summary>
/// Fake MXAccess event sink for testing.
/// </summary>
private sealed class FakeMxAccessEventSink : IMxAccessEventSink
{
/// <summary>
/// Gets the attached MXAccess COM object.
/// </summary>
public object? AttachedObject { get; private set; }
/// <summary>
/// Gets the managed thread ID when Attach was called.
/// </summary>
public int? AttachThreadId { get; private set; }
/// <summary>
/// Gets the managed thread ID when Detach was called.
/// </summary>
public int? DetachThreadId { get; private set; }
/// <summary>
/// Gets the session identifier.
/// </summary>
public string? SessionId { get; private set; }
/// <summary>
/// Attaches the MXAccess COM object and records thread context.
/// </summary>
/// <param name="mxAccessComObject">MXAccess COM object to attach.</param>
/// <param name="sessionId">Identifier of the session.</param>
public void Attach(
object mxAccessComObject,
string sessionId)
{
AttachedObject = mxAccessComObject;
AttachThreadId = Thread.CurrentThread.ManagedThreadId;
SessionId = sessionId;
}
/// <summary>
/// Detaches the MXAccess COM object and records thread context.
/// </summary>
public void Detach()
{
DetachThreadId = Thread.CurrentThread.ManagedThreadId;
AttachedObject = null;
}
}
/// <summary>
/// Gap 1: Verifies that when MxAccessStaSession is created with an alarm handler factory,
/// a SubscribeAlarms command dispatched through the session reaches the handler.
/// This proves the fix in WorkerPipeSession (and the new internal constructor) correctly
/// wires the factory rather than leaving alarmCommandHandler null.
/// </summary>
[Fact]
public async Task StartAsync_WithAlarmCommandHandlerFactory_SubscribeAlarmsCommandReachesHandler()
{
FakeAlarmCommandHandler handler = new();
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
StaCommand subscribeCommand = new StaCommand(
"session-1",
"corr-1",
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(handler.IsSubscribed);
Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
}
/// <summary>
/// Gap 1: Verifies that when MxAccessStaSession is created without an alarm
/// command handler factory, SubscribeAlarms returns InvalidRequest with the
/// exact "SubscribeAlarms requires an alarm command handler; the worker was
/// constructed without one." diagnostic. The full phrase is asserted so the
/// test fails if the diagnostic regresses to a misleading message that still
/// happens to contain the word "alarm".
/// </summary>
[Fact]
public async Task StartAsync_WithoutAlarmCommandHandlerFactory_SubscribeAlarmsReturnsInvalidRequest()
{
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
// Use the 4-arg (no factory) constructor — equivalent to the old MxAccessStaSession()
using MxAccessStaSession session = new(runtime, factory, eventSink);
await session.StartAsync("session-1", workerProcessId: 1);
StaCommand subscribeCommand = new StaCommand(
"session-1",
"corr-1",
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = await session.DispatchAsync(subscribeCommand);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
Assert.Equal(
"SubscribeAlarms requires an alarm command handler; the worker was constructed without one.",
reply.DiagnosticMessage);
}
/// <summary>
/// Gap 2: Verifies that after StartAsync with an alarm handler factory, the STA poll
/// loop calls PollOnce on the handler via the STA within a reasonable timeout.
/// This proves polling is driven by the STA rather than the consumer's internal timer.
/// </summary>
[Fact]
public async Task StartAsync_WithAlarmCommandHandlerFactory_PollOnceCalledViaSta()
{
FakeAlarmCommandHandler handler = new();
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
// Wait up to 3s for at least one PollOnce call from the STA poll loop.
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (handler.PollCount == 0 && !timeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(handler.PollCount > 0,
"Expected PollOnce to be called at least once by the STA poll loop within 3 seconds.");
Assert.NotNull(handler.LastPollThreadId);
Assert.Equal(runtime.StaThreadId, handler.LastPollThreadId);
}
/// <summary>
/// Gap 2: Verifies that the STA poll loop stops when the session is disposed —
/// no further PollOnce calls after disposal. <see cref="MxAccessStaSession.Dispose"/>
/// joins the poll task before returning, so once Dispose returns no PollOnce
/// call can still be in flight. The test asserts the poll count is frozen
/// immediately after Dispose and stays frozen — deterministic, with no
/// elapsed-time "no further polls" window that a slow agent could race.
/// </summary>
[Fact]
public async Task Dispose_StopsAlarmPollLoop()
{
FakeAlarmCommandHandler handler = new();
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
// using declaration: if an assertion below throws before the explicit
// Dispose, the session (its STA poll loop and alarm handler) is still
// torn down. Dispose is idempotent, so the explicit call mid-test and
// the using-scope call do not conflict.
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
// Wait for at least one poll to occur, then dispose.
using CancellationTokenSource initTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (handler.PollCount == 0 && !initTimeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(handler.PollCount > 0, "Prerequisite: poll loop must have fired before dispose.");
// Dispose joins the poll task; when it returns the loop has stopped
// and no PollOnce call is still running.
session.Dispose();
int pollCountAtDispose = handler.PollCount;
// The count is already frozen — re-reading after a yield must not
// observe any further poll. This is a deterministic check, not a
// timing window: a poll cannot start once the joined loop has exited.
await Task.Yield();
Assert.Equal(pollCountAtDispose, handler.PollCount);
}
/// <summary>
/// Worker-005 regression: when the alarm poll loop's PollOnce throws a
/// real failure (e.g. a COMException from GetXmlCurrentAlarms2), the
/// failure must be recorded as a fault on the event queue so a broken
/// alarm subscription becomes observable on the IPC fault path instead
/// of silently faulting the never-awaited poll task.
/// </summary>
[Fact]
public async Task RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue()
{
FakeAlarmCommandHandler handler = new()
{
PollException = new System.Runtime.InteropServices.COMException(
"GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)),
};
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
eventQueue,
(_eq, _affinity) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
// Wait up to 5s for the poll loop to fault the queue.
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(eventQueue.IsFaulted, "Expected the alarm poll failure to fault the event queue.");
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType);
}
/// <summary>
/// Worker-016 regression: the alarm poll loop's catch for the graceful
/// STA-runtime-shutdown signal must NOT also swallow a vanilla
/// <see cref="InvalidOperationException"/> raised from inside the marshalled
/// poll lambda — for example the STA-affinity assertion thrown by
/// <c>EnsureOnAlarmConsumerThread</c> if a regression ever caused the poll
/// to run off the alarm-consumer thread. The runtime-shutdown signal is now
/// the dedicated <see cref="StaRuntimeShutdownException"/>; a plain
/// <see cref="InvalidOperationException"/> from <c>PollOnce</c> must reach
/// the fault-recording arm and become observable on the event queue.
/// </summary>
[Fact]
public async Task RunAlarmPollLoop_WhenPollOnceThrowsInvalidOperation_RecordsFaultOnEventQueue()
{
FakeAlarmCommandHandler handler = new()
{
PollException = new InvalidOperationException(
"Alarm consumer accessed off its owning STA thread."),
};
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
eventQueue,
(_eq, _affinity) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(
eventQueue.IsFaulted,
"Expected the alarm poll InvalidOperationException to fault the event queue, "
+ "not be silently swallowed as a shutdown signal.");
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType);
Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Worker-008 regression: the STA-affinity guard throws when an
/// IMxAccessAlarmConsumer call is attempted off the thread that created
/// the consumer, mirroring the MxAccessSession.CreationThreadId invariant.
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws()
{
const int owningThread = 7;
const int otherThread = 99;
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread));
Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Worker-008: the STA-affinity guard is a no-op on the owning thread and
/// when no alarm consumer is configured (expected thread id null).
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow()
{
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42);
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123);
}
/// <summary>
/// Fake alarm command handler that records calls and tracks poll thread.
/// </summary>
private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler
{
private readonly object gate = new object();
private int pollCount;
private int? lastPollThreadId;
public bool IsSubscribed { get; private set; }
public string? LastSubscription { get; private set; }
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; }
public int PollCount
{
get { lock (gate) return pollCount; }
}
public int? LastPollThreadId
{
get { lock (gate) return lastPollThreadId; }
}
public void Subscribe(string subscription, string sessionId)
{
IsSubscribed = true;
LastSubscription = subscription;
}
public void Unsubscribe()
{
IsSubscribed = false;
}
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
=> 0;
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
=> 0;
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
=> Array.Empty<ActiveAlarmSnapshot>();
public void PollOnce()
{
lock (gate)
{
pollCount++;
lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
}
if (PollException is not null)
{
throw PollException;
}
}
public void Dispose() { }
}
}
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="MxAccessValueCache"/>. The cache is consumed by
/// <see cref="MxAccessSession.ReadBulk"/> to satisfy "current value"
/// requests for already-advised tags without touching the existing
/// subscription, so its contract is exercised in isolation here before any
/// STA / COM plumbing gets layered on top.
/// </summary>
public sealed class MxAccessValueCacheTests
{
[Fact]
public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion()
{
MxAccessValueCache cache = new();
Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));
cache.Set(serverHandle: 7, itemHandle: 21, BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp));
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue first));
Assert.Equal(1UL, first.Version);
Assert.Equal(100, first.Value.Int32Value);
Assert.Equal(192, first.Quality);
Assert.Equal(sourceTimestamp, first.SourceTimestamp);
// A second Set on the same key bumps the version and overwrites the
// payload. Different keys remain isolated.
cache.Set(7, 21, BuildEvent(7, 21, intValue: 200, quality: 192, sourceTimestamp));
cache.Set(7, 22, BuildEvent(7, 22, intValue: 999, quality: 192, sourceTimestamp));
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue second));
Assert.Equal(2UL, second.Version);
Assert.Equal(200, second.Value.Int32Value);
Assert.True(cache.TryGet(7, 22, out MxAccessValueCache.CachedValue other));
Assert.Equal(1UL, other.Version);
Assert.Equal(999, other.Value.Int32Value);
}
[Fact]
public void TryGet_WithUnknownHandle_ReturnsFalse()
{
MxAccessValueCache cache = new();
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
}
[Fact]
public void Remove_DropsEntryAndResetsVersion()
{
MxAccessValueCache cache = new();
cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
cache.Remove(7, 21);
Assert.False(cache.TryGet(7, 21, out _));
// After Remove, a subsequent Set restarts the per-handle version from 1
// — the cache must not serve a stale "version 3" entry that would race
// against a reused MXAccess item handle.
cache.Set(7, 21, BuildEvent(7, 21, intValue: 3, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue reset));
Assert.Equal(1UL, reset.Version);
}
[Fact]
public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown()
{
MxAccessValueCache cache = new();
Assert.Equal(0UL, cache.CurrentVersion(7, 21));
cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
}
/// <summary>
/// Worker.Tests-020: pins the contract that <c>TryWaitForUpdate</c>
/// returns <c>false</c> when the deadline has elapsed with no
/// <c>Set</c>, yields a default <c>CachedValue</c>, and invokes
/// <c>pumpStep</c> at least once so MXAccess Windows messages can
/// be dispatched. Earlier revisions of this test asserted both an
/// elapsed-time floor (<c>stopwatch.ElapsedMilliseconds &gt;= 60</c>)
/// and <c>pumpCalls &gt; 1</c> — the same wall-clock-floor race
/// pattern Worker.Tests-003/004/013 corrected. To eliminate the
/// timing dependency entirely (the equivalent of a manual time
/// source for a <c>DateTime.UtcNow</c>-based deadline), the test
/// now supplies a deadline already in the past: the loop pumps
/// once, observes the passed deadline, and returns false
/// deterministically without any <c>Thread.Sleep</c>. The
/// deadline-honouring contract is what this test exists to pin;
/// elapsed time and pump-iteration count are incidental.
/// </summary>
[Fact]
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
{
MxAccessValueCache cache = new();
int pumpCalls = 0;
// Deadline already in the past — eliminates the wall-clock-floor
// race. The loop must pump once (so MXAccess messages can dispatch
// on the calling thread even when the deadline has just expired)
// and then immediately observe the passed deadline.
DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1);
bool result = cache.TryWaitForUpdate(
serverHandle: 7,
itemHandle: 21,
sinceVersion: 0,
deadlineUtc: expiredDeadlineUtc,
pumpStep: () => Interlocked.Increment(ref pumpCalls),
out MxAccessValueCache.CachedValue value,
pollIntervalMs: 5);
Assert.False(result);
Assert.Equal(default, value.Value);
Assert.Equal(1, pumpCalls);
}
[Fact]
public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion()
{
MxAccessValueCache cache = new();
Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
// Baseline is "no entry yet" → wait for the first Set to land.
Task<(bool ok, MxAccessValueCache.CachedValue value)> waitTask = Task.Run(() =>
{
bool ok = cache.TryWaitForUpdate(
serverHandle: 7,
itemHandle: 21,
sinceVersion: 0,
deadlineUtc: DateTime.UtcNow.AddSeconds(2),
pumpStep: () => { },
out MxAccessValueCache.CachedValue v,
pollIntervalMs: 5);
return (ok, v);
});
// Race a Set against the wait loop. The cache's lock guarantees the
// wait observes the new version before TryGet returns it.
await Task.Delay(20);
cache.Set(7, 21, BuildEvent(7, 21, intValue: 4242, quality: 192, sourceTimestamp));
(bool ok, MxAccessValueCache.CachedValue value) = await waitTask;
Assert.True(ok);
Assert.Equal(4242, value.Value.Int32Value);
Assert.Equal(1UL, value.Version);
}
private static MxEvent BuildEvent(
int serverHandle,
int itemHandle,
int intValue,
int quality,
Timestamp sourceTimestamp)
{
MxEvent mxEvent = new()
{
Family = MxEventFamily.OnDataChange,
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Quality = quality,
SourceTimestamp = sourceTimestamp,
Value = new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = intValue,
},
OnDataChange = new OnDataChangeEvent(),
};
mxEvent.Statuses.Add(new MxStatusProxy
{
Category = MxStatusCategory.Ok,
});
return mxEvent;
}
}
@@ -0,0 +1,320 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
/// parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
/// dictionary, and the 32-char-hex GUID round-trip. The COM-side
/// polling loop is verified separately by the Skip-gated
/// <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
/// </summary>
public sealed class WnWrapAlarmConsumerXmlTests
{
/// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
private const string SingleAlarmActiveXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
"<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
"<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
"<GROUP>TestArea</GROUP>" +
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
"<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
"<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
"<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
"<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
"</ALARM_RECORDS>";
private const string EmptyXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
[Fact]
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
{
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
Assert.Empty(records);
}
[Fact]
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
{
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
}
[Fact]
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
{
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
Assert.Single(records);
Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
var record = records[expectedGuid];
Assert.Equal(expectedGuid, record.AlarmGuid);
Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
Assert.Equal("Galaxy", record.ProviderName);
Assert.Equal("TestArea", record.Group);
Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
Assert.Equal("DSC", record.Type);
Assert.Equal("true", record.Value);
Assert.Equal("true", record.Limit);
Assert.Equal(500, record.Priority);
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
Assert.Equal("Test alarm #1", record.AlarmComment);
Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
// 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
Assert.Equal(17, record.TransitionTimestampUtc.Hour);
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
}
[Fact]
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
{
string xml = SingleAlarmActiveXml.Replace(
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
"<GUID>not-a-guid</GUID>");
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
}
[Theory]
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected)
{
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
Assert.Equal(new Guid(expected), guid);
}
[Theory]
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
{
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
Assert.Equal(new Guid(canonical), guid);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("nope")]
[InlineData("0123456789ABCDEF")] // too short
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex)
{
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
Assert.Equal(Guid.Empty, guid);
}
/// <summary>
/// Worker-001 regression: the consumer must own no internal
/// <see cref="Timer"/>. A thread-pool timer calling the
/// apartment-threaded wnwrap COM object off its owning STA can
/// deadlock on cross-apartment marshaling, so the timer field and
/// callback must not exist on the type.
/// </summary>
[Fact]
public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField()
{
FieldInfo[] fields = typeof(WnWrapAlarmConsumer)
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Assert.DoesNotContain(fields, field => field.FieldType == typeof(Timer));
Assert.Null(typeof(WnWrapAlarmConsumer).GetMethod(
"OnPoll",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
}
/// <summary>
/// Worker-001 regression: no public constructor may accept a
/// poll-interval parameter. A non-zero poll interval was the only
/// way to arm the off-STA timer; removing the parameter makes the
/// footgun structurally unreachable.
/// </summary>
[Fact]
public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter()
{
foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer)
.GetConstructors(BindingFlags.Instance | BindingFlags.Public))
{
Assert.DoesNotContain(
constructor.GetParameters(),
parameter => parameter.Name is not null
&& parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0);
}
}
/// <summary>
/// Worker.Tests-022: pins the "new alarm sighting" branch of
/// <see cref="WnWrapAlarmConsumer.ComputeTransitions"/>. A GUID
/// that appears in <c>next</c> but not in <c>previous</c> must
/// produce exactly one transition with
/// <see cref="MxAlarmStateKind.Unspecified"/> as the previous
/// state — the proto layer relies on this sentinel to map a
/// first sighting to a <c>Raise</c>.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState()
{
Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new();
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State);
Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState);
}
/// <summary>
/// Worker.Tests-022: pins the "state unchanged" branch. A GUID
/// present in both snapshots with identical
/// <see cref="MxAlarmSnapshotRecord.State"/> must produce no
/// transition — a regression that emits a transition every poll
/// regardless of state change would slip through without this
/// test.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Empty(transitions);
}
/// <summary>
/// Worker.Tests-022: pins the "state changed" branch. A GUID
/// present in both snapshots with a different state must produce
/// one transition carrying the prior state so the proto layer
/// can distinguish e.g. <c>UnackAlm</c>→<c>AckAlm</c>
/// (Acknowledge) from <c>Unspecified</c>→<c>UnackAlm</c> (Raise).
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
}
/// <summary>
/// Worker.Tests-022: pins the "alarm cleared from the active set"
/// branch. AVEVA drops cleared alarms from
/// <c>GetXmlCurrentAlarms2</c>'s active set rather than emitting a
/// transition record. A GUID present in
/// <c>previous</c> but absent from <c>next</c> must therefore
/// produce no transition; the diff treats disappearance as an
/// implicit clear that the proto layer recognises by the missing
/// GUID, not by an emitted event.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new();
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Empty(transitions);
}
/// <summary>
/// Worker.Tests-022: pins the multi-alarm fan-out. Multiple
/// simultaneous transitions (new + changed + unchanged + dropped)
/// in one snapshot must produce exactly the changed and new
/// entries — not the unchanged and not the dropped.
/// </summary>
[Fact]
public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions()
{
Guid newGuid = Guid.NewGuid();
Guid changedGuid = Guid.NewGuid();
Guid unchangedGuid = Guid.NewGuid();
Guid droppedGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm),
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
[droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm),
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm),
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Equal(2, transitions.Count);
MxAlarmTransitionEvent newTransition = Assert.Single(
transitions,
t => t.Record.AlarmGuid == newGuid);
Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState);
Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State);
MxAlarmTransitionEvent changedTransition = Assert.Single(
transitions,
t => t.Record.AlarmGuid == changedGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState);
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
}
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
{
return new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
State = state,
TagName = "TestMachine.TestAlarm",
ProviderNode = "TEST-NODE",
ProviderName = "Galaxy",
};
}
}