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:
@@ -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 >= 60</c>)
|
||||
/// and <c>pumpCalls > 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user