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,94 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot reflection probe — discovers the public surface of
|
||||
/// <c>aaAlarmManagedClient.dll</c> so we can design the alarm-helper
|
||||
/// wiring in the worker. Marked Skip so it doesn't run as part of the
|
||||
/// normal suite; flip the Skip parameter to see the output.
|
||||
/// </summary>
|
||||
public sealed class AlarmClientDiscoveryTests
|
||||
{
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public AlarmClientDiscoveryTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")]
|
||||
public void DumpAlarmClientPublicSurface()
|
||||
{
|
||||
Assembly asm = Assembly.LoadFrom(@"C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll");
|
||||
|
||||
output.WriteLine($"Assembly: {asm.FullName}");
|
||||
output.WriteLine("Public types:");
|
||||
|
||||
Type[] types = asm.GetExportedTypes()
|
||||
.OrderBy(t => t.FullName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
foreach (Type t in types)
|
||||
{
|
||||
output.WriteLine($" {t.FullName} ({(t.IsClass ? "class" : t.IsInterface ? "interface" : t.IsEnum ? "enum" : "other")})");
|
||||
}
|
||||
|
||||
output.WriteLine("");
|
||||
output.WriteLine("Public events / methods on alarm-named types:");
|
||||
foreach (Type t in types.Where(x => x.Name.IndexOf("Alarm", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| x.Name.IndexOf("Subscription", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| x.Name.IndexOf("Event", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
output.WriteLine($" {t.FullName}");
|
||||
foreach (EventInfo e in t.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||
{
|
||||
output.WriteLine($" event {e.EventHandlerType?.Name} {e.Name}");
|
||||
}
|
||||
foreach (MethodInfo m in t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
if (m.IsSpecialName) continue;
|
||||
string parms = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
|
||||
output.WriteLine($" method {m.ReturnType.Name} {m.Name}({parms})");
|
||||
}
|
||||
}
|
||||
|
||||
// Probe AlarmRecord + enum types reachable from AlarmClient's module —
|
||||
// these are typically internal to the assembly but referenced by the
|
||||
// public API methods we just dumped.
|
||||
output.WriteLine("");
|
||||
output.WriteLine("Reachable types in the AlarmClient module:");
|
||||
Type alarmClient = asm.GetType("aaAlarmManagedClient.AlarmClient")!;
|
||||
foreach (Type t in alarmClient.Module.GetTypes()
|
||||
.Where(t => !t.IsNested)
|
||||
.OrderBy(t => t.FullName, StringComparer.Ordinal))
|
||||
{
|
||||
string visibility = t.IsPublic ? "public" : "internal";
|
||||
string kind = t.IsEnum ? "enum" : t.IsValueType ? "struct" : t.IsInterface ? "interface" : "class";
|
||||
output.WriteLine($" {visibility} {kind} {t.FullName}");
|
||||
if (t.IsEnum)
|
||||
{
|
||||
foreach (string n in Enum.GetNames(t))
|
||||
{
|
||||
object val = Enum.Parse(t, n);
|
||||
output.WriteLine($" {n} = {Convert.ToInt64(val)}");
|
||||
}
|
||||
}
|
||||
else if (t.Name.IndexOf("AlarmRecord", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.Name.IndexOf("Selected", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
|
||||
{
|
||||
output.WriteLine($" field {f.FieldType.Name} {f.Name}");
|
||||
}
|
||||
foreach (PropertyInfo p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
output.WriteLine($" prop {p.PropertyType.Name} {p.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory test implementation of the worker environment.
|
||||
/// </summary>
|
||||
internal sealed class MemoryWorkerEnvironment : IWorkerEnvironment
|
||||
{
|
||||
private readonly Dictionary<string, string> _values = new();
|
||||
private readonly Exception? _exception;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes an empty environment.
|
||||
/// </summary>
|
||||
public MemoryWorkerEnvironment()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes an environment that throws when accessed.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception to throw on access.</param>
|
||||
public MemoryWorkerEnvironment(Exception exception)
|
||||
{
|
||||
_exception = exception;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an environment variable in the in-memory store.
|
||||
/// </summary>
|
||||
/// <param name="name">Variable name.</param>
|
||||
/// <param name="value">Variable value.</param>
|
||||
public void Set(string name, string value)
|
||||
{
|
||||
_values[name] = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetEnvironmentVariable(string name)
|
||||
{
|
||||
if (_exception is not null)
|
||||
{
|
||||
throw _exception;
|
||||
}
|
||||
|
||||
return _values.TryGetValue(name, out string value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Captures a log entry for testing.
|
||||
/// </summary>
|
||||
internal sealed class MemoryWorkerLogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a log entry with level, event name, and fields.
|
||||
/// </summary>
|
||||
/// <param name="level">Log level (e.g., Debug, Info, Warning, Error).</param>
|
||||
/// <param name="eventName">Event name or category.</param>
|
||||
/// <param name="fields">Dictionary of log fields.</param>
|
||||
public MemoryWorkerLogEntry(
|
||||
string level,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Level = level;
|
||||
EventName = eventName;
|
||||
Fields = fields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log level.
|
||||
/// </summary>
|
||||
public string Level { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event name.
|
||||
/// </summary>
|
||||
public string EventName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the log entry fields.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object?> Fields { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory logger that records all log entries for test inspection.
|
||||
/// </summary>
|
||||
internal sealed class MemoryWorkerLogger : IWorkerLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// All logged entries recorded in memory.
|
||||
/// </summary>
|
||||
public List<MemoryWorkerLogEntry> Entries { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Information(string eventName, IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Entries.Add(new MemoryWorkerLogEntry("Information", eventName, WorkerLogRedactor.RedactFields(fields)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Error(string eventName, IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Entries.Add(new MemoryWorkerLogEntry("Error", eventName, WorkerLogRedactor.RedactFields(fields)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
public sealed class WorkerApplicationTests
|
||||
{
|
||||
/// <summary>Verifies that valid bootstrap arguments succeed and redact nonce.</summary>
|
||||
[Fact]
|
||||
public void Run_WithValidBootstrapArguments_ReturnsSuccessAndLogsRedactedNonce()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret");
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
ValidArgs(),
|
||||
environment,
|
||||
logger,
|
||||
new SucceedingPipeClient());
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.Success, exitCode);
|
||||
Assert.Equal(2, logger.Entries.Count);
|
||||
MemoryWorkerLogEntry entry = logger.Entries[0];
|
||||
Assert.Equal("Information", entry.Level);
|
||||
Assert.Equal("WorkerBootstrapSucceeded", entry.EventName);
|
||||
Assert.Equal("session-1", entry.Fields["session_id"]);
|
||||
Assert.Equal("mxaccess-gateway-123-session-1", entry.Fields["pipe_name"]);
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, entry.Fields["protocol_version"]);
|
||||
Assert.Equal("[redacted]", entry.Fields["nonce"]);
|
||||
Assert.Equal("WorkerPipeSessionCompleted", logger.Entries[1].EventName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that missing arguments returns invalid arguments error.</summary>
|
||||
[Fact]
|
||||
public void Run_WithMissingRequiredArguments_ReturnsInvalidArguments()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret");
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
[],
|
||||
environment,
|
||||
logger);
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.InvalidArguments, exitCode);
|
||||
MemoryWorkerLogEntry entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal("Error", entry.Level);
|
||||
Assert.Equal("WorkerBootstrapFailed", entry.EventName);
|
||||
Assert.Equal(WorkerExitCode.InvalidArguments, entry.Fields["exit_code"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid protocol version is rejected.</summary>
|
||||
[Fact]
|
||||
public void Run_WithInvalidProtocolVersion_ReturnsInvalidProtocolVersion()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret");
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
ValidArgs(protocolVersion: "999"),
|
||||
environment,
|
||||
logger);
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.InvalidProtocolVersion, exitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that missing nonce is detected.</summary>
|
||||
[Fact]
|
||||
public void Run_WithMissingNonce_ReturnsMissingNonce()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = new();
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
ValidArgs(),
|
||||
environment,
|
||||
logger);
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.MissingNonce, exitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that pipe protocol failure returns protocol violation error.</summary>
|
||||
[Fact]
|
||||
public void Run_WithPipeProtocolFailure_ReturnsProtocolViolation()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = CreateEnvironment("nonce-secret");
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
ValidArgs(),
|
||||
environment,
|
||||
logger,
|
||||
new ThrowingPipeClient(new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.NonceMismatch,
|
||||
"Bad nonce.")));
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.ProtocolViolation, exitCode);
|
||||
Assert.Equal("WorkerPipeProtocolFailure", logger.Entries[1].EventName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unexpected exceptions during bootstrap are logged.</summary>
|
||||
[Fact]
|
||||
public void Run_WithUnexpectedBootstrapFailure_ReturnsUnexpectedFailure()
|
||||
{
|
||||
MemoryWorkerEnvironment environment = new(new InvalidOperationException("environment failed"));
|
||||
MemoryWorkerLogger logger = new();
|
||||
|
||||
int exitCode = ZB.MOM.WW.MxGateway.Worker.WorkerApplication.Run(
|
||||
ValidArgs(),
|
||||
environment,
|
||||
logger);
|
||||
|
||||
Assert.Equal((int)WorkerExitCode.UnexpectedFailure, exitCode);
|
||||
MemoryWorkerLogEntry entry = Assert.Single(logger.Entries);
|
||||
Assert.Equal("WorkerBootstrapUnexpectedFailure", entry.EventName);
|
||||
Assert.Equal(WorkerExitCode.UnexpectedFailure, entry.Fields["exit_code"]);
|
||||
Assert.Equal(typeof(InvalidOperationException).FullName, entry.Fields["exception_type"]);
|
||||
}
|
||||
|
||||
private static string[] ValidArgs(string? protocolVersion = null)
|
||||
{
|
||||
return
|
||||
[
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--pipe-name",
|
||||
"mxaccess-gateway-123-session-1",
|
||||
"--protocol-version",
|
||||
protocolVersion ?? GatewayContractInfo.WorkerProtocolVersion.ToString(),
|
||||
];
|
||||
}
|
||||
|
||||
private static MemoryWorkerEnvironment CreateEnvironment(string nonce)
|
||||
{
|
||||
MemoryWorkerEnvironment environment = new();
|
||||
environment.Set(WorkerOptions.NonceEnvironmentVariableName, nonce);
|
||||
return environment;
|
||||
}
|
||||
|
||||
private sealed class SucceedingPipeClient : IWorkerPipeClient
|
||||
{
|
||||
/// <summary>Runs the worker pipe client successfully.</summary>
|
||||
/// <param name="options">Worker options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
public Task RunAsync(
|
||||
WorkerOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingPipeClient : IWorkerPipeClient
|
||||
{
|
||||
private readonly Exception _exception;
|
||||
|
||||
/// <summary>Initializes the pipe client with an exception to throw.</summary>
|
||||
/// <param name="exception">Exception to throw when run.</param>
|
||||
public ThrowingPipeClient(Exception exception)
|
||||
{
|
||||
_exception = exception;
|
||||
}
|
||||
|
||||
/// <summary>Runs the worker pipe client and throws configured exception.</summary>
|
||||
/// <param name="options">Worker options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Never completes; always throws.</returns>
|
||||
public Task RunAsync(
|
||||
WorkerOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw _exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
public sealed class WorkerConsoleLoggerTests
|
||||
{
|
||||
/// <summary>Verifies that console logger redacts nonce in structured output.</summary>
|
||||
[Fact]
|
||||
public void Information_RedactsNonceInStructuredOutput()
|
||||
{
|
||||
StringWriter writer = new();
|
||||
WorkerConsoleLogger logger = new(writer);
|
||||
|
||||
logger.Information("WorkerBootstrapSucceeded", new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = "session-1",
|
||||
["nonce"] = "nonce-secret",
|
||||
});
|
||||
|
||||
string output = writer.ToString();
|
||||
|
||||
Assert.Contains("event=WorkerBootstrapSucceeded", output);
|
||||
Assert.Contains("session_id=session-1", output);
|
||||
Assert.Contains("nonce=[redacted]", output);
|
||||
Assert.DoesNotContain("nonce-secret", output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
public sealed class WorkerLogRedactorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies sensitive fields are redacted in log dictionaries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RedactFields_RedactsNonceSecretPasswordTokenCredentialAndApiKeyFields()
|
||||
{
|
||||
Dictionary<string, object?> fields = new()
|
||||
{
|
||||
["nonce"] = "nonce-secret",
|
||||
["client_secret"] = "secret",
|
||||
["password"] = "password",
|
||||
["auth_token"] = "token",
|
||||
["credential_value"] = "credential",
|
||||
["api_key"] = "key",
|
||||
["session_id"] = "session-1",
|
||||
};
|
||||
|
||||
Dictionary<string, object?> redacted = WorkerLogRedactor.RedactFields(fields);
|
||||
|
||||
Assert.Equal("[redacted]", redacted["nonce"]);
|
||||
Assert.Equal("[redacted]", redacted["client_secret"]);
|
||||
Assert.Equal("[redacted]", redacted["password"]);
|
||||
Assert.Equal("[redacted]", redacted["auth_token"]);
|
||||
Assert.Equal("[redacted]", redacted["credential_value"]);
|
||||
Assert.Equal("[redacted]", redacted["api_key"]);
|
||||
Assert.Equal("session-1", redacted["session_id"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="WorkerLogRedactor.RedactValue"/> redacts individual
|
||||
/// credential-bearing fields before they reach a log sink.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RedactValue_WithCredentialBearingFieldNames_ReturnsRedactedValue()
|
||||
{
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("credential_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("password_value", "secret"));
|
||||
Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Bootstrap;
|
||||
|
||||
public sealed class WorkerOptionsParserTests
|
||||
{
|
||||
/// <summary>Verifies that parsing with all required inputs returns worker options.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithAllRequiredInputs_ReturnsWorkerOptions()
|
||||
{
|
||||
WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret"));
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(ValidArgs());
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(WorkerExitCode.Success, result.ExitCode);
|
||||
Assert.NotNull(result.Options);
|
||||
Assert.Equal("session-1", result.Options.SessionId);
|
||||
Assert.Equal("mxaccess-gateway-123-session-1", result.Options.PipeName);
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, result.Options.ProtocolVersion);
|
||||
Assert.Equal("nonce-secret", result.Options.Nonce);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing with missing session ID returns invalid arguments.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithMissingSessionId_ReturnsInvalidArguments()
|
||||
{
|
||||
WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret"));
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(
|
||||
[
|
||||
"--pipe-name",
|
||||
"mxaccess-gateway-123-session-1",
|
||||
"--protocol-version",
|
||||
GatewayContractInfo.WorkerProtocolVersion.ToString(),
|
||||
]);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(WorkerExitCode.InvalidArguments, result.ExitCode);
|
||||
Assert.Contains(result.Errors, error => error.Contains("--session-id"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing with unknown option returns invalid arguments.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithUnknownOption_ReturnsInvalidArguments()
|
||||
{
|
||||
WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret"));
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(
|
||||
[
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--pipe-name",
|
||||
"mxaccess-gateway-123-session-1",
|
||||
"--protocol-version",
|
||||
GatewayContractInfo.WorkerProtocolVersion.ToString(),
|
||||
"--unexpected",
|
||||
"value",
|
||||
]);
|
||||
|
||||
Assert.Equal(WorkerExitCode.InvalidArguments, result.ExitCode);
|
||||
Assert.Contains(result.Errors, error => error.Contains("Unknown option"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing with non-numeric protocol version returns invalid protocol version.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithNonNumericProtocolVersion_ReturnsInvalidProtocolVersion()
|
||||
{
|
||||
WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret"));
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(ValidArgs(protocolVersion: "abc"));
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing with unsupported protocol version returns invalid protocol version.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithUnsupportedProtocolVersion_ReturnsInvalidProtocolVersion()
|
||||
{
|
||||
WorkerOptionsParser parser = new(CreateEnvironment("nonce-secret"));
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(ValidArgs(protocolVersion: "999"));
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that parsing with missing nonce returns missing nonce error.</summary>
|
||||
[Fact]
|
||||
public void Parse_WithMissingNonce_ReturnsMissingNonce()
|
||||
{
|
||||
WorkerOptionsParser parser = new(new MemoryWorkerEnvironment());
|
||||
|
||||
WorkerBootstrapResult result = parser.Parse(ValidArgs());
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(WorkerExitCode.MissingNonce, result.ExitCode);
|
||||
}
|
||||
|
||||
private static string[] ValidArgs(string? protocolVersion = null)
|
||||
{
|
||||
return
|
||||
[
|
||||
"--session-id",
|
||||
"session-1",
|
||||
"--pipe-name",
|
||||
"mxaccess-gateway-123-session-1",
|
||||
"--protocol-version",
|
||||
protocolVersion ?? GatewayContractInfo.WorkerProtocolVersion.ToString(),
|
||||
];
|
||||
}
|
||||
|
||||
private static MemoryWorkerEnvironment CreateEnvironment(string nonce)
|
||||
{
|
||||
MemoryWorkerEnvironment environment = new();
|
||||
environment.Set(WorkerOptions.NonceEnvironmentVariableName, nonce);
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Contracts;
|
||||
|
||||
public sealed class WorkerContractInfoTests
|
||||
{
|
||||
/// <summary>Verifies that the supported protocol version matches the gateway contract version.</summary>
|
||||
[Fact]
|
||||
public void SupportedProtocolVersion_UsesSharedGatewayContractVersion()
|
||||
{
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, WorkerContractInfo.SupportedProtocolVersion);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the worker envelope descriptor name matches the generated protobuf contract.</summary>
|
||||
[Fact]
|
||||
public void WorkerEnvelopeDescriptorName_UsesGeneratedWorkerContract()
|
||||
{
|
||||
Assert.Equal("mxaccess_worker.v1.WorkerEnvelope", WorkerContractInfo.WorkerEnvelopeDescriptorName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HResultConverter"/>.
|
||||
/// </summary>
|
||||
public sealed class HResultConverterTests
|
||||
{
|
||||
private readonly HResultConverter _converter = new();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Convert captures the HResult from a COM exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Convert_WithComException_CapturesExceptionHResult()
|
||||
{
|
||||
COMException exception = new("Sensitive provider text should not be copied.", unchecked((int)0x80070057));
|
||||
|
||||
HResultConversion converted = _converter.Convert(exception);
|
||||
|
||||
Assert.Equal(unchecked((int)0x80070057), converted.HResult);
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code);
|
||||
Assert.Contains("0x80070057", converted.ProtocolStatus.Message);
|
||||
Assert.Contains(typeof(COMException).FullName!, converted.DiagnosticMessage);
|
||||
Assert.DoesNotContain("Sensitive provider text", converted.DiagnosticMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CreateProtocolStatus returns OK for a success HResult.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateProtocolStatus_WithSuccessHResult_ReturnsOk()
|
||||
{
|
||||
ProtocolStatus status = _converter.CreateProtocolStatus(0);
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, status.Code);
|
||||
Assert.Equal("HRESULT 0x00000000", status.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Convert captures the HResult from a non-COM exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Convert_WithNonComException_CapturesExceptionHResult()
|
||||
{
|
||||
InvalidOperationException exception = new("do not include this");
|
||||
|
||||
HResultConversion converted = _converter.Convert(exception);
|
||||
|
||||
Assert.Equal(exception.HResult, converted.HResult);
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code);
|
||||
Assert.Contains("0x", converted.DiagnosticMessage);
|
||||
Assert.DoesNotContain("do not include this", converted.DiagnosticMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion;
|
||||
|
||||
public sealed class MxStatusProxyConverterTests
|
||||
{
|
||||
private readonly MxStatusProxyConverter _converter = new();
|
||||
|
||||
/// <summary>Verifies that status struct fields are preserved during conversion.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithStatusStruct_PreservesStatusFields()
|
||||
{
|
||||
FakeMxStatusProxy status = new()
|
||||
{
|
||||
success = 1,
|
||||
category = 5,
|
||||
detectedBy = 3,
|
||||
detail = 21,
|
||||
};
|
||||
|
||||
MxStatusProxy converted = _converter.Convert(status);
|
||||
|
||||
Assert.Equal(1, converted.Success);
|
||||
Assert.Equal(MxStatusCategory.OperationalError, converted.Category);
|
||||
Assert.Equal(MxStatusSource.RespondingNmx, converted.DetectedBy);
|
||||
Assert.Equal(21, converted.Detail);
|
||||
Assert.Equal(5, converted.RawCategory);
|
||||
Assert.Equal(3, converted.RawDetectedBy);
|
||||
Assert.Equal("Invalid reference", converted.DiagnosticText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that status arrays are converted without collapsing duplicate entries.</summary>
|
||||
[Fact]
|
||||
public void ConvertMany_WithStatusArray_DoesNotCollapseEntries()
|
||||
{
|
||||
FakeMxStatusProxy[] statuses =
|
||||
[
|
||||
new()
|
||||
{
|
||||
success = 1,
|
||||
category = 0,
|
||||
detectedBy = 0,
|
||||
detail = 0,
|
||||
},
|
||||
new()
|
||||
{
|
||||
success = 0,
|
||||
category = 6,
|
||||
detectedBy = 5,
|
||||
detail = 33,
|
||||
},
|
||||
];
|
||||
|
||||
IReadOnlyList<MxStatusProxy> converted = _converter.ConvertMany(statuses);
|
||||
|
||||
Assert.Equal(2, converted.Count);
|
||||
Assert.Equal(MxStatusCategory.Ok, converted[0].Category);
|
||||
Assert.Equal(MxStatusCategory.SecurityError, converted[1].Category);
|
||||
Assert.Equal(MxStatusSource.RespondingAutomationObject, converted[1].DetectedBy);
|
||||
Assert.Equal("Write access denied", converted[1].DiagnosticText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown category and source values preserve raw field values.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithUnknownCategoryAndSource_PreservesRawFields()
|
||||
{
|
||||
FakeMxStatusProxy status = new()
|
||||
{
|
||||
success = -1,
|
||||
category = 99,
|
||||
detectedBy = 42,
|
||||
detail = 1234,
|
||||
};
|
||||
|
||||
MxStatusProxy converted = _converter.Convert(status);
|
||||
|
||||
Assert.Equal(-1, converted.Success);
|
||||
Assert.Equal(MxStatusCategory.Unknown, converted.Category);
|
||||
Assert.Equal(MxStatusSource.Unknown, converted.DetectedBy);
|
||||
Assert.Equal(99, converted.RawCategory);
|
||||
Assert.Equal(42, converted.RawDetectedBy);
|
||||
Assert.Equal(1234, converted.Detail);
|
||||
Assert.Equal(string.Empty, converted.DiagnosticText);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that completion-only status bytes are preserved as hex metadata.</summary>
|
||||
[Fact]
|
||||
public void PreserveCompletionOnlyStatusBytes_ReturnsRawHexMetadata()
|
||||
{
|
||||
string rawStatus = _converter.PreserveCompletionOnlyStatusBytes(
|
||||
[0x00, 0x00, 0x50, 0x80, 0x00]);
|
||||
|
||||
Assert.Equal("completion_only_status_hex=0000508000", rawStatus);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that missing status fields throw a conversion exception.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithMissingStatusField_ThrowsConversionException()
|
||||
{
|
||||
MxStatusConversionException exception =
|
||||
Assert.Throws<MxStatusConversionException>(() => _converter.Convert(new MissingFields()));
|
||||
|
||||
Assert.Contains("success", exception.Message);
|
||||
}
|
||||
|
||||
public struct FakeMxStatusProxy
|
||||
{
|
||||
public short success;
|
||||
|
||||
public int category;
|
||||
|
||||
public int detectedBy;
|
||||
|
||||
public short detail;
|
||||
}
|
||||
|
||||
private sealed class MissingFields
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using Google.Protobuf;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
using ProtobufTimestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Conversion;
|
||||
|
||||
public sealed class VariantConverterTests
|
||||
{
|
||||
private readonly VariantConverter _converter = new();
|
||||
|
||||
/// <summary>Verifies that supported scalar types are converted with correct data type and value kind.</summary>
|
||||
/// <param name="value">Scalar value to convert.</param>
|
||||
/// <param name="expectedDataType">Expected MxDataType of the converted value.</param>
|
||||
/// <param name="expectedKind">Expected KindOneofCase of the converted value.</param>
|
||||
[Theory]
|
||||
[InlineData(true, MxDataType.Boolean, MxValue.KindOneofCase.BoolValue)]
|
||||
[InlineData(42, MxDataType.Integer, MxValue.KindOneofCase.Int32Value)]
|
||||
[InlineData(42L, MxDataType.Integer, MxValue.KindOneofCase.Int64Value)]
|
||||
[InlineData(1.25f, MxDataType.Float, MxValue.KindOneofCase.FloatValue)]
|
||||
[InlineData(2.5d, MxDataType.Double, MxValue.KindOneofCase.DoubleValue)]
|
||||
[InlineData("value", MxDataType.String, MxValue.KindOneofCase.StringValue)]
|
||||
public void Convert_WithSupportedScalar_ProjectsTypedValue(
|
||||
object value,
|
||||
MxDataType expectedDataType,
|
||||
MxValue.KindOneofCase expectedKind)
|
||||
{
|
||||
MxValue converted = _converter.Convert(value);
|
||||
|
||||
Assert.Equal(expectedDataType, converted.DataType);
|
||||
Assert.Equal(expectedKind, converted.KindCase);
|
||||
Assert.False(string.IsNullOrWhiteSpace(converted.VariantType));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DateTime values are converted to protobuf timestamps.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithDateTime_ProjectsTimestamp()
|
||||
{
|
||||
DateTime dateTime = new(2026, 4, 26, 17, 45, 0, DateTimeKind.Utc);
|
||||
|
||||
MxValue converted = _converter.Convert(dateTime);
|
||||
|
||||
Assert.Equal(MxDataType.Time, converted.DataType);
|
||||
Assert.Equal(ProtobufTimestamp.FromDateTime(dateTime), converted.TimestampValue);
|
||||
Assert.Equal("VT_DATE", converted.VariantType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that scalar MxValue kinds convert to the matching boxed CLR type for a COM write.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithInt32_ReturnsBoxedInt()
|
||||
{
|
||||
object? result = _converter.ConvertToComValue(new MxValue { Int32Value = 123 });
|
||||
|
||||
Assert.Equal(123, Assert.IsType<int>(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a boolean MxValue converts to a boxed bool for a COM write.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithBool_ReturnsBoxedBool()
|
||||
{
|
||||
object? result = _converter.ConvertToComValue(new MxValue { BoolValue = true });
|
||||
|
||||
Assert.True(Assert.IsType<bool>(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a string MxValue converts to a string for a COM write.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithString_ReturnsString()
|
||||
{
|
||||
object? result = _converter.ConvertToComValue(new MxValue { StringValue = "abc" });
|
||||
|
||||
Assert.Equal("abc", Assert.IsType<string>(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a timestamp MxValue converts to a UTC DateTime the COM marshaler renders as VT_DATE.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithTimestamp_ReturnsUtcDateTime()
|
||||
{
|
||||
DateTime dateTime = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
object? result = _converter.ConvertToComValue(
|
||||
new MxValue { TimestampValue = ProtobufTimestamp.FromDateTime(dateTime) });
|
||||
|
||||
Assert.Equal(dateTime, Assert.IsType<DateTime>(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an MXAccess-null MxValue converts to a CLR null.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithNull_ReturnsNull()
|
||||
{
|
||||
object? result = _converter.ConvertToComValue(new MxValue { IsNull = true });
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an integer-array MxValue converts to an int array the COM marshaler renders as a SAFEARRAY.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithInt32Array_ReturnsInt32Array()
|
||||
{
|
||||
MxValue value = new()
|
||||
{
|
||||
ArrayValue = new MxArray
|
||||
{
|
||||
Int32Values = new Int32Array { Values = { 1, 2, 3 } },
|
||||
},
|
||||
};
|
||||
|
||||
object? result = _converter.ConvertToComValue(value);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, Assert.IsType<int[]>(result));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an MxValue with no value kind set cannot be converted for a COM write.</summary>
|
||||
[Fact]
|
||||
public void ConvertToComValue_WithNoKind_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => _converter.ConvertToComValue(new MxValue()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file time values with expected time data type are converted to protobuf timestamps.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithFileTimeAndExpectedTime_ProjectsTimestamp()
|
||||
{
|
||||
DateTime dateTime = new(2026, 4, 26, 17, 45, 0, DateTimeKind.Utc);
|
||||
|
||||
MxValue converted = _converter.Convert(dateTime.ToFileTimeUtc(), MxDataType.Time);
|
||||
|
||||
Assert.Equal(MxDataType.Time, converted.DataType);
|
||||
Assert.Equal(ProtobufTimestamp.FromDateTime(dateTime), converted.TimestampValue);
|
||||
Assert.Equal("VT_I8", converted.VariantType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-010 regression: a 32-bit <see cref="uint"/> with an expected
|
||||
/// data type of <see cref="MxDataType.Time"/> must not be projected as a
|
||||
/// Windows FILETIME. A uint can only hold the low 32 bits of a FILETIME,
|
||||
/// which would silently render as a near-1601 timestamp; the converter
|
||||
/// must fall through to an integer projection instead.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Convert_WithUInt32AndExpectedTime_DoesNotProjectFileTime()
|
||||
{
|
||||
const uint value = 123456789u;
|
||||
|
||||
MxValue converted = _converter.Convert(value, MxDataType.Time);
|
||||
|
||||
Assert.Equal(MxDataType.Integer, converted.DataType);
|
||||
Assert.Equal(MxValue.KindOneofCase.Int64Value, converted.KindCase);
|
||||
Assert.Equal(value, converted.Int64Value);
|
||||
Assert.Equal("VT_UI4", converted.VariantType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null-like values preserve their null semantics and variant type.</summary>
|
||||
/// <param name="value">Null-like value to convert.</param>
|
||||
/// <param name="expectedVariantType">Expected variant type string.</param>
|
||||
[Theory]
|
||||
[InlineData(null, "VT_EMPTY")]
|
||||
[InlineData(typeof(DBNull), "VT_NULL")]
|
||||
public void Convert_WithNullLikeValue_PreservesNull(
|
||||
object? value,
|
||||
string expectedVariantType)
|
||||
{
|
||||
object? actualValue = value is System.Type ? DBNull.Value : value;
|
||||
|
||||
MxValue converted = _converter.Convert(actualValue);
|
||||
|
||||
Assert.True(converted.IsNull);
|
||||
Assert.Equal(MxDataType.NoData, converted.DataType);
|
||||
Assert.Equal(expectedVariantType, converted.VariantType);
|
||||
Assert.Equal(MxValue.KindOneofCase.None, converted.KindCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that supported array types are converted with correct element type and dimensions.</summary>
|
||||
[Fact]
|
||||
public void ConvertArray_WithSupportedArrays_ProjectsTypedValuesAndDimensions()
|
||||
{
|
||||
MxValue bools = _converter.Convert(new[] { true, false });
|
||||
MxValue ints = _converter.Convert(new[] { 1, 2, 3 });
|
||||
MxValue floats = _converter.Convert(new[] { 1.25f, 2.5f });
|
||||
MxValue doubles = _converter.Convert(new[] { 1.25d, 2.5d });
|
||||
MxValue strings = _converter.Convert(new[] { "one", "two" });
|
||||
MxValue times = _converter.Convert(new[]
|
||||
{
|
||||
new DateTime(2026, 4, 26, 17, 45, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 4, 26, 17, 46, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { true, false }, bools.ArrayValue.BoolValues.Values);
|
||||
Assert.Equal(new[] { 1, 2, 3 }, ints.ArrayValue.Int32Values.Values);
|
||||
Assert.Equal(new[] { 1.25f, 2.5f }, floats.ArrayValue.FloatValues.Values);
|
||||
Assert.Equal(new[] { 1.25d, 2.5d }, doubles.ArrayValue.DoubleValues.Values);
|
||||
Assert.Equal(new[] { "one", "two" }, strings.ArrayValue.StringValues.Values);
|
||||
Assert.Equal(2, times.ArrayValue.TimestampValues.Values.Count);
|
||||
Assert.Equal(new uint[] { 2 }, bools.ArrayValue.Dimensions);
|
||||
Assert.Equal(MxDataType.Boolean, bools.ArrayValue.ElementDataType);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that multidimensional arrays preserve rank and dimension information.</summary>
|
||||
[Fact]
|
||||
public void ConvertArray_WithMultidimensionalArray_PreservesRankAndDimensions()
|
||||
{
|
||||
int[,] values =
|
||||
{
|
||||
{ 1, 2, 3 },
|
||||
{ 4, 5, 6 },
|
||||
};
|
||||
|
||||
MxValue converted = _converter.Convert(values);
|
||||
|
||||
Assert.Equal(new uint[] { 2, 3 }, converted.ArrayValue.Dimensions);
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, converted.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that file time arrays with expected time data type are converted to timestamp arrays.</summary>
|
||||
[Fact]
|
||||
public void ConvertArray_WithExpectedTimeAndFileTimeValues_ProjectsTimestampArray()
|
||||
{
|
||||
DateTime first = new(2026, 4, 26, 17, 45, 0, DateTimeKind.Utc);
|
||||
DateTime second = new(2026, 4, 26, 17, 46, 0, DateTimeKind.Utc);
|
||||
|
||||
MxValue converted = _converter.Convert(
|
||||
new[] { first.ToFileTimeUtc(), second.ToFileTimeUtc() },
|
||||
MxDataType.Time);
|
||||
|
||||
Assert.Equal(MxDataType.Time, converted.ArrayValue.ElementDataType);
|
||||
Assert.Equal(
|
||||
new[] { ProtobufTimestamp.FromDateTime(first), ProtobufTimestamp.FromDateTime(second) },
|
||||
converted.ArrayValue.TimestampValues.Values);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown scalar types preserve raw value and diagnostic metadata.</summary>
|
||||
[Fact]
|
||||
public void Convert_WithUnknownScalar_PreservesRawMetadata()
|
||||
{
|
||||
UnsupportedVariant value = new("opaque");
|
||||
|
||||
MxValue converted = _converter.Convert(value);
|
||||
|
||||
Assert.Equal(MxDataType.Unknown, converted.DataType);
|
||||
Assert.Equal(MxValue.KindOneofCase.RawValue, converted.KindCase);
|
||||
Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.VariantType);
|
||||
Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.RawDiagnostic);
|
||||
Assert.Equal(ByteString.CopyFromUtf8("opaque"), converted.RawValue);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown array types preserve raw values and diagnostic metadata.</summary>
|
||||
[Fact]
|
||||
public void ConvertArray_WithUnknownArray_PreservesRawMetadata()
|
||||
{
|
||||
UnsupportedVariant[] values =
|
||||
[
|
||||
new("first"),
|
||||
new("second"),
|
||||
];
|
||||
|
||||
MxValue converted = _converter.Convert(values);
|
||||
|
||||
Assert.Equal(MxDataType.Unknown, converted.ArrayValue.ElementDataType);
|
||||
Assert.Equal(MxArray.ValuesOneofCase.RawValues, converted.ArrayValue.ValuesCase);
|
||||
Assert.Equal(new uint[] { 2 }, converted.ArrayValue.Dimensions);
|
||||
Assert.Equal("first", converted.ArrayValue.RawValues.Values[0].ToStringUtf8());
|
||||
Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.ArrayValue.RawDiagnostic);
|
||||
}
|
||||
|
||||
/// <summary>Fake unsupported variant type for testing unknown type handling.</summary>
|
||||
private sealed class UnsupportedVariant
|
||||
{
|
||||
private readonly string _value;
|
||||
|
||||
/// <summary>Initializes a new instance of the UnsupportedVariant class.</summary>
|
||||
/// <param name="value">The opaque value.</param>
|
||||
public UnsupportedVariant(string value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return _value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
public sealed class WorkerFrameProtocolTests
|
||||
{
|
||||
private const string SessionId = "session-1";
|
||||
private const string Nonce = "nonce-secret";
|
||||
|
||||
/// <summary>Verifies that valid envelopes round-trip through write and read.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new();
|
||||
WorkerEnvelope original = CreateGatewayHelloEnvelope();
|
||||
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
await writer.WriteAsync(original);
|
||||
stream.Position = 0;
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerEnvelope parsed = await reader.ReadAsync();
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that wrong protocol version throws mismatch error.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.ProtocolVersion++;
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that wrong session ID throws mismatch error.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.SessionId = "different-session";
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(envelope));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix is zero is rejected before the
|
||||
/// payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c> states the
|
||||
/// reader rejects zero-length payloads as a malformed-length error. The
|
||||
/// length prefix is the leading four bytes of the stream, so a four-zero-byte
|
||||
/// stream is exactly a frame declaring a zero-length payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithZeroLengthPayload_ThrowsMalformedLength()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(new byte[sizeof(uint)]);
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a frame whose length prefix exceeds the configured maximum
|
||||
/// is rejected before the payload buffer is allocated. <c>docs/WorkerFrameProtocol.md</c>
|
||||
/// states the reader rejects oversized payloads as a message-too-large error.
|
||||
/// A small maximum is configured so the rejection is asserted without
|
||||
/// allocating a multi-megabyte buffer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithPayloadAboveConfiguredMaximum_ThrowsMessageTooLarge()
|
||||
{
|
||||
const int maxMessageBytes = 64;
|
||||
WorkerFrameProtocolOptions options = new(
|
||||
SessionId,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce,
|
||||
maxMessageBytes);
|
||||
byte[] frame = new byte[sizeof(uint)];
|
||||
WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, maxMessageBytes + 1);
|
||||
using MemoryStream stream = new(frame);
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that malformed payload throws invalid envelope error.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new(WorkerFrameTestHelpers.CreateFrame(new byte[] { 0x80 }));
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-021 (a): pins the <c>EndOfStream</c> branch of
|
||||
/// <c>WorkerFrameReader.ReadExactlyOrThrowAsync</c>. The gateway
|
||||
/// closing its end of the pipe during a partial-frame read is the
|
||||
/// most common production transport failure; the reader must
|
||||
/// surface this as <c>WorkerFrameProtocolErrorCode.EndOfStream</c>
|
||||
/// so the worker session can fault deterministically rather than
|
||||
/// spinning on a partial buffer. The stream here declares a 100-byte
|
||||
/// payload but only supplies 50 bytes, so the inner read loop sees
|
||||
/// <c>bytesRead == 0</c> mid-frame.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WhenStreamEndsMidFrame_ThrowsEndOfStream()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
byte[] frame = new byte[sizeof(uint) + 50];
|
||||
WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, 100);
|
||||
using MemoryStream stream = new(frame);
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await reader.ReadAsync());
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.EndOfStream, exception.ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-021 (b): pins the writer-side
|
||||
/// <c>MessageTooLarge</c> branch. A session that constructs an
|
||||
/// envelope whose serialised size exceeds <c>MaxMessageBytes</c>
|
||||
/// must be rejected by the writer before any bytes are sent down
|
||||
/// the pipe, so a misbehaving producer cannot push the receiver
|
||||
/// past its bounds. A small <c>MaxMessageBytes</c> is configured
|
||||
/// so a modest <c>GatewayHello</c> payload — with its nonce
|
||||
/// padded out to several hundred bytes — exceeds the limit
|
||||
/// without allocating anything large.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithEnvelopeAboveConfiguredMaximum_ThrowsMessageTooLarge()
|
||||
{
|
||||
const int maxMessageBytes = 64;
|
||||
WorkerFrameProtocolOptions options = new(
|
||||
SessionId,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce,
|
||||
maxMessageBytes);
|
||||
using MemoryStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
|
||||
envelope.GatewayHello.GatewayVersion = new string('x', 1024);
|
||||
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await writer.WriteAsync(envelope));
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
|
||||
Assert.Equal(0, stream.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker.Tests-021 (c): documents that the writer-side
|
||||
/// <c>InvalidEnvelope</c> branch (raised when
|
||||
/// <c>WorkerEnvelope.CalculateSize()</c> returns 0) is unreachable
|
||||
/// through public API. <c>WorkerEnvelopeValidator.Validate</c> (run
|
||||
/// before the size check in <c>WorkerFrameWriter.WriteAsync</c>)
|
||||
/// rejects any envelope whose <c>BodyCase</c> is <c>None</c> with
|
||||
/// <c>InvalidEnvelope</c>; a body-less envelope is therefore
|
||||
/// intercepted before the empty-payload branch can fire. Any
|
||||
/// envelope carrying a typed body serialises at least the field
|
||||
/// tag bytes, so <c>CalculateSize()</c> is strictly positive. This
|
||||
/// test exercises the body-less path and asserts the same
|
||||
/// <c>InvalidEnvelope</c> error code reaches the caller, pinning
|
||||
/// the contract that "no body" is rejected before any size check.
|
||||
/// The defensive zero-length branch in <c>WriteAsync</c> is left
|
||||
/// in place because the cost is one comparison and removing it
|
||||
/// would weaken the writer against future serialisation
|
||||
/// regressions; this test makes its rationale visible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithEmptyEnvelope_ThrowsInvalidEnvelopeFromValidator()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
WorkerEnvelope envelope = new()
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = SessionId,
|
||||
Sequence = 1,
|
||||
// No body — BodyCase == None, validator rejects.
|
||||
};
|
||||
|
||||
WorkerFrameProtocolException exception =
|
||||
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
|
||||
async () => await writer.WriteAsync(envelope));
|
||||
|
||||
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
|
||||
Assert.Equal(0, stream.Length);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent writes produce complete serialized frames.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_WithConcurrentCalls_SerializesCompleteFrames()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
await Task.WhenAll(
|
||||
writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 1)),
|
||||
writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 2)),
|
||||
writer.WriteAsync(CreateGatewayHelloEnvelope(sequence: 3)));
|
||||
|
||||
stream.Position = 0;
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
|
||||
WorkerEnvelope first = await reader.ReadAsync();
|
||||
WorkerEnvelope second = await reader.ReadAsync();
|
||||
WorkerEnvelope third = await reader.ReadAsync();
|
||||
|
||||
Assert.Equal(new ulong[] { 1, 2, 3 }, new[] { first.Sequence, second.Sequence, third.Sequence }.OrderBy(sequence => sequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-009 regression: the reader rents its payload buffer from a
|
||||
/// shared pool, so a rented buffer can be larger than the current frame
|
||||
/// and may carry bytes from a previous, larger frame. Reading frames of
|
||||
/// differing sizes back-to-back through one reader must parse each frame
|
||||
/// using only its own payload length, never trailing pooled bytes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WithVaryingFrameSizes_ParsesEachFrameExactly()
|
||||
{
|
||||
WorkerFrameProtocolOptions options = CreateOptions();
|
||||
using MemoryStream stream = new();
|
||||
WorkerFrameWriter writer = new(stream, options);
|
||||
|
||||
// A large-payload frame followed by a small-payload frame: if the
|
||||
// reader reused a pooled buffer without honouring the second frame's
|
||||
// length, the small frame would parse with stale trailing bytes.
|
||||
WorkerEnvelope large = CreateGatewayHelloEnvelope(sequence: 1);
|
||||
large.GatewayHello.GatewayVersion = new string('x', 4096);
|
||||
WorkerEnvelope small = CreateGatewayHelloEnvelope(sequence: 2);
|
||||
|
||||
await writer.WriteAsync(large);
|
||||
await writer.WriteAsync(small);
|
||||
stream.Position = 0;
|
||||
|
||||
WorkerFrameReader reader = new(stream, options);
|
||||
WorkerEnvelope firstParsed = await reader.ReadAsync();
|
||||
WorkerEnvelope secondParsed = await reader.ReadAsync();
|
||||
|
||||
Assert.Equal(large, firstParsed);
|
||||
Assert.Equal(small, secondParsed);
|
||||
}
|
||||
|
||||
private static WorkerFrameProtocolOptions CreateOptions()
|
||||
{
|
||||
return new WorkerFrameProtocolOptions(
|
||||
SessionId,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce);
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreateGatewayHelloEnvelope(ulong sequence = 1)
|
||||
{
|
||||
return new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = SessionId,
|
||||
Sequence = sequence,
|
||||
GatewayHello = new GatewayHello
|
||||
{
|
||||
SupportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce = Nonce,
|
||||
GatewayVersion = "test-gateway",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
public sealed class WorkerPipeClientTests
|
||||
{
|
||||
/// <summary>Verifies that worker client connects and completes handshake.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ConnectsToPipeAndCompletesHandshake()
|
||||
{
|
||||
string pipeName = $"mxaccess-gateway-test-{Guid.NewGuid():N}";
|
||||
WorkerOptions workerOptions = new(
|
||||
"session-1",
|
||||
pipeName,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
"nonce-secret");
|
||||
WorkerFrameProtocolOptions frameOptions = new(workerOptions);
|
||||
|
||||
using NamedPipeServerStream server = new(
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
WorkerPipeClient client = new(
|
||||
connectTimeoutMilliseconds: 5000,
|
||||
(stream, options) => CreateSession(stream, options));
|
||||
Task clientTask = client.RunAsync(workerOptions);
|
||||
|
||||
await Task.Factory.FromAsync(server.BeginWaitForConnection, server.EndWaitForConnection, null);
|
||||
|
||||
WorkerFrameReader reader = new(server, frameOptions);
|
||||
WorkerFrameWriter writer = new(server, frameOptions);
|
||||
|
||||
await writer.WriteAsync(new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 1,
|
||||
GatewayHello = new GatewayHello
|
||||
{
|
||||
SupportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce = "nonce-secret",
|
||||
GatewayVersion = "test-gateway",
|
||||
},
|
||||
});
|
||||
|
||||
WorkerEnvelope hello = await reader.ReadAsync();
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, hello.BodyCase);
|
||||
Assert.Equal("nonce-secret", hello.WorkerHello.Nonce);
|
||||
|
||||
WorkerEnvelope ready = await reader.ReadAsync();
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, ready.BodyCase);
|
||||
|
||||
await writer.WriteAsync(new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 2,
|
||||
WorkerShutdown = new WorkerShutdown
|
||||
{
|
||||
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)),
|
||||
Reason = "test-complete",
|
||||
},
|
||||
});
|
||||
|
||||
WorkerEnvelope shutdownAck = await ReadUntilAsync(
|
||||
reader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerShutdownAck);
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, shutdownAck.BodyCase);
|
||||
await clientTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that worker client retries until pipe server becomes available.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_RetriesUntilPipeServerAppears()
|
||||
{
|
||||
string pipeName = $"mxaccess-gateway-test-{Guid.NewGuid():N}";
|
||||
WorkerOptions workerOptions = new(
|
||||
"session-1",
|
||||
pipeName,
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
"nonce-secret");
|
||||
WorkerFrameProtocolOptions frameOptions = new(workerOptions);
|
||||
|
||||
WorkerPipeClient client = new(
|
||||
logger: null,
|
||||
connectTimeoutMilliseconds: 1000,
|
||||
connectAttemptTimeoutMilliseconds: 50,
|
||||
(stream, options, _) => CreateSession(stream, options));
|
||||
Task clientTask = client.RunAsync(workerOptions);
|
||||
|
||||
await Task.Delay(150);
|
||||
|
||||
using NamedPipeServerStream server = new(
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await Task.Factory.FromAsync(server.BeginWaitForConnection, server.EndWaitForConnection, null);
|
||||
|
||||
WorkerFrameReader reader = new(server, frameOptions);
|
||||
WorkerFrameWriter writer = new(server, frameOptions);
|
||||
|
||||
await writer.WriteAsync(CreateGatewayHello());
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, (await reader.ReadAsync()).BodyCase);
|
||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, (await reader.ReadAsync()).BodyCase);
|
||||
await writer.WriteAsync(CreateShutdown());
|
||||
|
||||
Assert.Equal(
|
||||
WorkerEnvelope.BodyOneofCase.WorkerShutdownAck,
|
||||
(await ReadUntilAsync(reader, WorkerEnvelope.BodyOneofCase.WorkerShutdownAck)).BodyCase);
|
||||
await clientTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that worker client throws timeout if pipe never appears.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenPipeNeverAppears_ThrowsTimeoutException()
|
||||
{
|
||||
WorkerOptions workerOptions = new(
|
||||
"session-1",
|
||||
$"mxaccess-gateway-test-{Guid.NewGuid():N}",
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
"nonce-secret");
|
||||
|
||||
WorkerPipeClient client = new(
|
||||
logger: null,
|
||||
connectTimeoutMilliseconds: 100,
|
||||
connectAttemptTimeoutMilliseconds: 50,
|
||||
(stream, options, _) => CreateSession(stream, options));
|
||||
|
||||
await Assert.ThrowsAsync<TimeoutException>(async () => await client.RunAsync(workerOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads frames until one matching the expected body case is found,
|
||||
/// skipping interleaved heartbeats (the first heartbeat is emitted
|
||||
/// immediately on entering the heartbeat loop — see Worker-002).
|
||||
/// </summary>
|
||||
private static async Task<WorkerEnvelope> ReadUntilAsync(
|
||||
WorkerFrameReader reader,
|
||||
WorkerEnvelope.BodyOneofCase expectedBody)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
WorkerEnvelope envelope = await reader.ReadAsync();
|
||||
if (envelope.BodyCase == expectedBody)
|
||||
{
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkerPipeSession CreateSession(
|
||||
Stream stream,
|
||||
WorkerFrameProtocolOptions options)
|
||||
{
|
||||
return new WorkerPipeSession(
|
||||
new WorkerFrameReader(stream, options),
|
||||
new WorkerFrameWriter(stream, options),
|
||||
options,
|
||||
() => 1234,
|
||||
new WorkerPipeSessionOptions
|
||||
{
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(30),
|
||||
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||
},
|
||||
() => new FakeRuntimeSession());
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreateGatewayHello()
|
||||
{
|
||||
return new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 1,
|
||||
GatewayHello = new GatewayHello
|
||||
{
|
||||
SupportedProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
Nonce = "nonce-secret",
|
||||
GatewayVersion = "test-gateway",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkerEnvelope CreateShutdown()
|
||||
{
|
||||
return new WorkerEnvelope
|
||||
{
|
||||
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
SessionId = "session-1",
|
||||
Sequence = 2,
|
||||
WorkerShutdown = new WorkerShutdown
|
||||
{
|
||||
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)),
|
||||
Reason = "test-complete",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AlarmMgrDataProviderCOM;
|
||||
using aaAlarmManagedClient;
|
||||
using ArchestrA.MxAccess;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — registers as an AlarmClient consumer with a real
|
||||
/// hidden message-only window, subscribes to a Galaxy alarm provider,
|
||||
/// and logs every Win32 message that arrives during a fixed pump
|
||||
/// window. The intent is to identify the WM_APP / RegisterWindowMessage
|
||||
/// ID that AVEVA's alarm provider posts when alarms change, plus the
|
||||
/// <c>wParam</c>/<c>lParam</c> semantics on each.
|
||||
///
|
||||
/// Skip-gated by default; flip Skip=null and run against the live dev
|
||||
/// rig to capture output. Requires:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A reachable Galaxy with at least one alarmable object.</description></item>
|
||||
/// <item><description>The configured Galaxy expression below to match a real provider (default <c>"\\Galaxy"</c> — adjust if needed).</description></item>
|
||||
/// <item><description>An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class AlarmClientWmProbeTests : IDisposable
|
||||
{
|
||||
// Probe configuration. Override in the constructor below if needed.
|
||||
// Try multiple subscription expressions sequentially (each Subscribe call
|
||||
// adds to the consumer's scope). The "everything" form varies by AVEVA
|
||||
// version — we shotgun common forms.
|
||||
// Canonical AlarmClient subscription format (per ArchestrA docs):
|
||||
// \\Node\Provider!Area!Filter
|
||||
// - Node: machine name (NOT galaxy name; "Galaxy" is the literal provider)
|
||||
// - Provider: literal "Galaxy"
|
||||
// - Area: area object the engine hosts the alarm under
|
||||
// Note: each Subscribe call REPLACES the prior subscription on the
|
||||
// consumer, so we test exactly one expression per probe run.
|
||||
private static readonly string MachineName = Environment.MachineName;
|
||||
private static readonly string[] SubscriptionExpressions =
|
||||
{
|
||||
// DEV is the top-level area on the Platform (TestArea is contained
|
||||
// within DEV). Alarms typically publish at the platform's primary
|
||||
// area. If TestArea-only doesn't catch them, DEV should.
|
||||
$@"\\{MachineName}\Galaxy!DEV",
|
||||
};
|
||||
private const string SubscriptionExpression = @"\Galaxy!";
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
|
||||
private static readonly TimeSpan FireMarkerAt = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan ClearMarkerAt = TimeSpan.FromSeconds(35);
|
||||
// Tag the operator should flip while the probe is pumping. Default
|
||||
// matches the dev rig's known alarmable boolean.
|
||||
private const string TriggerTagReference = "TestMachine_001.TestAlarm001";
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
|
||||
private static extern IntPtr CreateWindowEx(
|
||||
int dwExStyle, string lpClassName, string lpWindowName,
|
||||
int dwStyle, int X, int Y, int nWidth, int nHeight,
|
||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetModuleHandle(string lpModuleName);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint RegisterWindowMessage(string lpString);
|
||||
|
||||
private const int HWND_MESSAGE = -3;
|
||||
private const uint PM_REMOVE = 0x0001;
|
||||
|
||||
private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct WNDCLASSW
|
||||
{
|
||||
public uint style;
|
||||
public IntPtr lpfnWndProc;
|
||||
public int cbClsExtra;
|
||||
public int cbWndExtra;
|
||||
public IntPtr hInstance;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr hCursor;
|
||||
public IntPtr hbrBackground;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
private GCHandle wndProcHandle;
|
||||
private IntPtr probeWindow = IntPtr.Zero;
|
||||
private string? registeredClass;
|
||||
|
||||
public AlarmClientWmProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
|
||||
public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
|
||||
{
|
||||
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
|
||||
// matches in the captured log can be labeled. None of these is
|
||||
// confirmed; we record what each resolves to so the actual AVEVA
|
||||
// message ID (whatever it turns out to be) can be cross-referenced.
|
||||
string[] candidateNames =
|
||||
{
|
||||
"WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm",
|
||||
"WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify",
|
||||
"WNAL_Notify", "WNAL_ChangeNotification",
|
||||
"AlarmManager.Notify", "AlarmManagerNotify",
|
||||
"ArchestrA.AlarmChange", "AVEVA.AlarmNotify",
|
||||
"aaAlarmManagedClient.Notify",
|
||||
"GotAlarmChanges", "OnAlarmChanges",
|
||||
};
|
||||
foreach (string name in candidateNames)
|
||||
{
|
||||
uint id = RegisterWindowMessage(name);
|
||||
output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})");
|
||||
}
|
||||
output.WriteLine("");
|
||||
|
||||
// 2. Spin up a single STA-affinitized thread, create a hidden message-
|
||||
// only window owned by it, run RegisterConsumer + Subscribe against
|
||||
// that window's hWnd, then pump messages on that thread for the
|
||||
// configured duration. Threading discipline matches the worker's
|
||||
// StaRuntime model.
|
||||
Exception? threadException = null;
|
||||
var pumpDone = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
RunProbe();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
threadException = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
pumpDone.Set();
|
||||
}
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
pumpDone.Wait();
|
||||
thread.Join();
|
||||
|
||||
// 3. Drain the log to xunit output regardless of outcome — partial
|
||||
// captures are still informative.
|
||||
output.WriteLine("");
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
// 3a. Register a window class and create a message-only window.
|
||||
WndProc wndProc = ProbeWndProc;
|
||||
wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive
|
||||
|
||||
registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N");
|
||||
var cls = new WNDCLASSW
|
||||
{
|
||||
style = 0,
|
||||
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc),
|
||||
hInstance = GetModuleHandle(null!),
|
||||
lpszClassName = registeredClass,
|
||||
};
|
||||
ushort atom = RegisterClassW(ref cls);
|
||||
if (atom == 0)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"RegisterClass failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}");
|
||||
|
||||
probeWindow = CreateWindowEx(
|
||||
dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe",
|
||||
dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0,
|
||||
hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero,
|
||||
hInstance: cls.hInstance, lpParam: IntPtr.Zero);
|
||||
if (probeWindow == IntPtr.Zero)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}");
|
||||
return;
|
||||
}
|
||||
Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}");
|
||||
|
||||
// 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer
|
||||
// accepts an int hWnd — narrow the IntPtr (sufficient on x86).
|
||||
AlarmClient? client = null;
|
||||
try
|
||||
{
|
||||
client = new AlarmClient();
|
||||
|
||||
// One-time interop introspection: dump AlarmClient's class GUID
|
||||
// (CoClass IID) and every interface it implements with their
|
||||
// GUID + InterfaceType. The IID we need to redeclare with safe
|
||||
// blittable types is the one whose vtable carries
|
||||
// GetHighPriAlarm.
|
||||
try
|
||||
{
|
||||
Type ct = client.GetType();
|
||||
Log($"=== AlarmClient interop introspection ===");
|
||||
Log($"Class FullName: {ct.FullName}");
|
||||
var classGuid = ct.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
|
||||
Log($"Class GUID: {classGuid?.Value ?? "(none)"}");
|
||||
foreach (var iface in ct.GetInterfaces())
|
||||
{
|
||||
var ig = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
|
||||
var ity = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.InterfaceTypeAttribute), true)
|
||||
.Cast<System.Runtime.InteropServices.InterfaceTypeAttribute>().FirstOrDefault();
|
||||
int methodCount = iface.GetMethods().Length;
|
||||
Log($" iface {iface.FullName} | GUID={ig?.Value ?? "(none)"} | type={ity?.Value.ToString() ?? "(none)"} | methods={methodCount}");
|
||||
}
|
||||
// Dump fields (private/internal) — the COM object reference
|
||||
// is likely on a private field.
|
||||
Log($"--- AlarmClient instance fields ---");
|
||||
foreach (var f in ct.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
|
||||
{
|
||||
Log($" field {f.FieldType.FullName} {f.Name} (public={f.IsPublic})");
|
||||
}
|
||||
// Dump base class chain.
|
||||
Log($"--- base class chain ---");
|
||||
Type? baseT = ct.BaseType;
|
||||
int depth = 0;
|
||||
while (baseT != null && depth < 5)
|
||||
{
|
||||
Log($" base[{depth}]: {baseT.FullName}");
|
||||
baseT = baseT.BaseType;
|
||||
depth++;
|
||||
}
|
||||
Log($"=== end introspection ===");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Interop introspection threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Try InitializeConsumer first — separate from RegisterConsumer
|
||||
// per the discovered API surface; previous probe runs skipped
|
||||
// it. Some AVEVA managed-client patterns require Initialize
|
||||
// before Register; others reverse the order. Try Initialize
|
||||
// first; on failure proceed to Register.
|
||||
try
|
||||
{
|
||||
int init = client.InitializeConsumer("AlarmProbe.Tests");
|
||||
Log($"InitializeConsumer -> {init}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
int register = client.RegisterConsumer(
|
||||
hWnd: probeWindow.ToInt32(),
|
||||
szProductName: "AlarmProbe",
|
||||
szApplicationName: "AlarmProbe.Tests",
|
||||
szVersion: "1.0",
|
||||
bRetainHiddenAlarms: false);
|
||||
Log($"RegisterConsumer -> {register}");
|
||||
|
||||
LogProviders(client, "after Register");
|
||||
|
||||
// Dump the eQueryType enum so we can see what alternatives exist
|
||||
// beyond qtSummary, in case Summary aggregates and we need a
|
||||
// List/Snapshot mode instead.
|
||||
try
|
||||
{
|
||||
Type qt = typeof(eQueryType);
|
||||
Log($"eQueryType enum values: " +
|
||||
string.Join(", ", Enum.GetNames(qt).Select(n =>
|
||||
$"{n}=0x{Convert.ToInt32(Enum.Parse(qt, n)):X}")));
|
||||
Type af = typeof(eAlarmFilterState);
|
||||
Log($"eAlarmFilterState enum values: " +
|
||||
string.Join(", ", Enum.GetNames(af).Select(n =>
|
||||
$"{n}=0x{Convert.ToInt32(Enum.Parse(af, n)):X}")));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Enum dump threw: {ex.Message}");
|
||||
}
|
||||
|
||||
// qtHistory + state=ActiveNow: stream historical alarm transitions
|
||||
// including active alarms. asNone for FilterMask/Spec might
|
||||
// literally mean "match alarms in state 'none'" (i.e., nothing),
|
||||
// since the eAlarmFilterState enum is 0/1/2/3 single-states not
|
||||
// flag bits. Try ActiveNow explicitly.
|
||||
// Subscribe to every candidate expression — AVEVA accepts multiple
|
||||
// overlapping subscriptions; whichever matches the producer wins.
|
||||
foreach (string expr in SubscriptionExpressions)
|
||||
{
|
||||
try
|
||||
{
|
||||
int subscribe = client.Subscribe(
|
||||
szSubscription: expr,
|
||||
wFromPri: 0, wToPri: short.MaxValue,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
Log($"Subscribe('{expr}') -> {subscribe}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
LogProviders(client, "after Subscribe-multi");
|
||||
|
||||
// 3c. Pump for the configured duration. Log every message we see
|
||||
// (filtered light to avoid noise from WM_PAINT / WM_TIMER /
|
||||
// WM_GETICON spam from typical pumps). Poll GetStatistics on
|
||||
// a tight cadence so any alarm transition is captured. Print
|
||||
// "fire" / "clear" markers at fixed wallclock offsets so the
|
||||
// operator can flip the trigger boolean during the run.
|
||||
Log($"Probe running for {PumpDuration.TotalSeconds:F0}s. " +
|
||||
$"Observing {TriggerTagReference} alarm transitions. " +
|
||||
"External trigger expected from System Platform script (10s flip cadence).");
|
||||
|
||||
DateTime probeStart = DateTime.UtcNow;
|
||||
DateTime deadline = probeStart + PumpDuration;
|
||||
DateTime nextPoll = probeStart + PollInterval;
|
||||
int pollCount = 0;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||
{
|
||||
LogIfInteresting(msg);
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
// Trigger is supplied externally — a System Platform script
|
||||
// flips TestMachine_001.TestAlarm001 every 10s. The probe
|
||||
// observes only.
|
||||
if (DateTime.UtcNow >= nextPoll)
|
||||
{
|
||||
PollGetStatistics(client, ++pollCount);
|
||||
LogProviders(client, $"poll #{pollCount}");
|
||||
PollAllChannels(client, pollCount);
|
||||
nextPoll = DateTime.UtcNow + PollInterval;
|
||||
}
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering.");
|
||||
Log($"GetHighPriAlarm tally: ok-with-record={getHighPriOk} threw={getHighPriThrow} " +
|
||||
$"(throws indicate alarm-record marshaling failure; ok=empty record).");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { client?.Dispose(); } catch { /* swallow */ }
|
||||
if (probeWindow != IntPtr.Zero)
|
||||
{
|
||||
DestroyWindow(probeWindow);
|
||||
probeWindow = IntPtr.Zero;
|
||||
}
|
||||
if (registeredClass != null)
|
||||
{
|
||||
UnregisterClassW(registeredClass, GetModuleHandle(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string lastStatsSummary = string.Empty;
|
||||
private string lastProvidersSummary = string.Empty;
|
||||
private string lastHighPriSummary = string.Empty;
|
||||
private string lastSfStatsSummary = string.Empty;
|
||||
private int getHighPriOk = 0;
|
||||
private int getHighPriThrow = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Try every read API the AlarmClient exposes and log when its
|
||||
/// output changes. AlarmClient has at least three distinct read
|
||||
/// surfaces — GetStatistics (current-change array), GetHighPriAlarm
|
||||
/// (single-record peek), and the SF (stored filter) family — and any
|
||||
/// of them might be the populated one.
|
||||
/// </summary>
|
||||
private static AlarmRecord NewAlarmRecord()
|
||||
{
|
||||
// The interop's auto-marshal flips DateTime fields to FILETIME on
|
||||
// the way IN as well as OUT. default(DateTime) (year 1) is outside
|
||||
// FILETIME's representable range, so initialize all DateTime fields
|
||||
// to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler.
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
foreach (var f in typeof(AlarmRecord).GetFields(
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
|
||||
{
|
||||
if (f.FieldType == typeof(DateTime))
|
||||
{
|
||||
object boxed = rec;
|
||||
f.SetValue(boxed, epoch);
|
||||
rec = (AlarmRecord)boxed;
|
||||
}
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
private void PollAllChannels(AlarmClient client, int seq)
|
||||
{
|
||||
// Channel A: GetHighPriAlarm — peek highest-priority alarm. Track
|
||||
// outcome state (record/empty/throw) and log every transition AND
|
||||
// total counts at end. The throw correlates with an alarm being
|
||||
// present (AVEVA fills timestamps with sentinel FILETIME values
|
||||
// that crash the .NET marshaler) — useful as a presence signal
|
||||
// even if we can't read the record.
|
||||
try
|
||||
{
|
||||
AlarmRecord rec = NewAlarmRecord();
|
||||
int rc = client.GetHighPriAlarm(ref rec);
|
||||
string desc = rc == 0 ? DescribeAlarmRecord(rec) : "<no record>";
|
||||
string summary = $"rc={rc} {desc}";
|
||||
getHighPriOk++;
|
||||
if (summary != lastHighPriSummary)
|
||||
{
|
||||
Log($"GetHighPriAlarm #{seq}: {summary} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
|
||||
lastHighPriSummary = summary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string es = $"{ex.GetType().Name}";
|
||||
getHighPriThrow++;
|
||||
if (es != lastHighPriSummary)
|
||||
{
|
||||
Log($"GetHighPriAlarm #{seq}: threw {es} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
|
||||
lastHighPriSummary = es;
|
||||
}
|
||||
}
|
||||
|
||||
// Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly;
|
||||
// populated alarms (if any) appear at low indices.
|
||||
for (int idx = 0; idx <= 2; idx++)
|
||||
{
|
||||
try
|
||||
{
|
||||
AlarmRecord rec = NewAlarmRecord();
|
||||
int rc = client.GetAlarmExtendedRec(idx, ref rec);
|
||||
if (rc == 0)
|
||||
{
|
||||
string desc = DescribeAlarmRecord(rec);
|
||||
Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}");
|
||||
break; // log first present record only
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (idx == 0)
|
||||
{
|
||||
Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Channel B: SF — snapshot + GetStatistics + iterate.
|
||||
try
|
||||
{
|
||||
uint numAlarms = 0;
|
||||
int sfCreate = client.SFCreateSnapshot(0, ref numAlarms);
|
||||
int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0;
|
||||
int sfStats = client.SFGetStatistics(
|
||||
ref unackRet, ref unackAlm, ref ackAlm,
|
||||
ref others, ref events, ref idxNewest);
|
||||
string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " +
|
||||
$"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " +
|
||||
$"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}";
|
||||
if (summary != lastSfStatsSummary)
|
||||
{
|
||||
Log($"SF channel #{seq}: {summary} (changed)");
|
||||
lastSfStatsSummary = summary;
|
||||
|
||||
// If non-zero, fetch the first record by index via the
|
||||
// standard GetAlarmExtendedRec — after SFCreateSnapshot the
|
||||
// indices reference the snapshot.
|
||||
if (numAlarms > 0)
|
||||
{
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
int recRc = client.GetAlarmExtendedRec(0, ref rec);
|
||||
Log($" GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}");
|
||||
}
|
||||
}
|
||||
client.SFDeleteSnapshot();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void LogProviders(AlarmClient client, string when)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providers = new System.Collections.Generic.List<string>();
|
||||
int rc = client.GetProviders(providers);
|
||||
string summary = $"count={providers.Count} list=[{string.Join(", ", providers)}]";
|
||||
if (summary != lastProvidersSummary)
|
||||
{
|
||||
Log($"GetProviders [{when}] -> rc={rc} {summary} (changed)");
|
||||
lastProvidersSummary = summary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetProviders [{when}] threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drive an MxAccess write to <see cref="TriggerTagReference"/> with the
|
||||
/// supplied boolean value. Creates a fresh `LMXProxyServer` COM object,
|
||||
/// registers, adds the item, writes the value, and tears down. Runs on
|
||||
/// the same STA thread the probe uses for the AlarmClient — both COM
|
||||
/// objects share the apartment, which matches the worker's runtime.
|
||||
/// </summary>
|
||||
private void TriggerWriteValue(bool value, int sequence)
|
||||
{
|
||||
object? lmx = null;
|
||||
ILMXProxyServer? srv = null;
|
||||
int handle = 0, itemHandle = 0;
|
||||
try
|
||||
{
|
||||
lmx = new LMXProxyServerClass();
|
||||
srv = (ILMXProxyServer)lmx;
|
||||
handle = srv.Register($"AlarmProbe.Trigger.{sequence}");
|
||||
Log($"Trigger write #{sequence}: Register -> handle={handle}");
|
||||
itemHandle = srv.AddItem(handle, TriggerTagReference);
|
||||
Log($"Trigger write #{sequence}: AddItem('{TriggerTagReference}') -> itemHandle={itemHandle}");
|
||||
|
||||
// First time only: dump every Write* method's signature so we know
|
||||
// which to call. The first attempt hit TargetParameterCountException —
|
||||
// the LMX server has multiple Write variants and we picked wrong.
|
||||
if (sequence == 1)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: enumerating Write* methods on {lmx.GetType().FullName}:");
|
||||
foreach (var m in lmx.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (m.IsSpecialName) continue;
|
||||
if (!m.Name.StartsWith("Write", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
string ps = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
|
||||
Log($" {m.ReturnType.Name} {m.Name}({ps})");
|
||||
}
|
||||
}
|
||||
|
||||
// Late-bind Write — it isn't on ILMXProxyServer's interface but is
|
||||
// exposed by the COM coclass.
|
||||
object[] writeArgs = new object[] { handle, itemHandle, value };
|
||||
object? rv = lmx.GetType().InvokeMember(
|
||||
"Write",
|
||||
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null, target: lmx, args: writeArgs);
|
||||
Log($"Trigger write #{sequence}: Write({TriggerTagReference}={value}) -> rv={rv}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: FAILED: {ex.GetType().Name}: {ex.Message}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Log($" inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (srv != null && itemHandle != 0) { srv.RemoveItem(handle, itemHandle); }
|
||||
if (srv != null && handle != 0) { srv.Unregister(handle); }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Trigger write #{sequence}: cleanup failure: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
if (lmx != null && System.Runtime.InteropServices.Marshal.IsComObject(lmx))
|
||||
{
|
||||
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(lmx); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void PollGetStatistics(AlarmClient client, int seq)
|
||||
{
|
||||
try
|
||||
{
|
||||
int percent = 0, total = 0, active = 0, suppressed = 0;
|
||||
int suppressedFilters = 0, newAlarms = 0, changes = 0;
|
||||
int[] codes = Array.Empty<int>();
|
||||
int[] positions = Array.Empty<int>();
|
||||
int[] handles = Array.Empty<int>();
|
||||
int rc = client.GetStatistics(
|
||||
ref percent, ref total, ref active, ref suppressed,
|
||||
ref suppressedFilters, ref newAlarms, ref changes,
|
||||
ref codes, ref positions, ref handles);
|
||||
string codesStr = codes != null ? string.Join(",", codes) : "<null>";
|
||||
string posStr = positions != null ? string.Join(",", positions) : "<null>";
|
||||
string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
|
||||
int posLen = positions?.Length ?? 0;
|
||||
|
||||
// Suppress duplicate-summary spam — only log when interesting
|
||||
// state-change is observed. The "interesting" digest excludes
|
||||
// percent (always 100 at steady state).
|
||||
string summary = $"total={total} active={active} suppressed={suppressed} " +
|
||||
$"new={newAlarms} changes={changes} codes=[{codesStr}] " +
|
||||
$"positions=[{posStr}] handles=[{handlesStr}]";
|
||||
if (summary != lastStatsSummary)
|
||||
{
|
||||
Log($"GetStatistics #{seq} rc={rc} pct={percent} {summary} (changed)");
|
||||
lastStatsSummary = summary;
|
||||
}
|
||||
|
||||
// Always fetch records when positions has entries — records
|
||||
// change content even when count stays the same.
|
||||
if (posLen > 0 && positions != null)
|
||||
{
|
||||
for (int i = 0; i < Math.Min(posLen, 4); i++)
|
||||
{
|
||||
int idx = positions[i];
|
||||
AlarmRecord rec = new AlarmRecord();
|
||||
int recRc = client.GetAlarmExtendedRec(idx, ref rec);
|
||||
Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " +
|
||||
DescribeAlarmRecord(rec));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeAlarmRecord(AlarmRecord rec)
|
||||
{
|
||||
// Reflect over the record's public properties so we don't have to
|
||||
// guess the field shape — the discovery probe already showed it has
|
||||
// ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc.
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("{ ");
|
||||
bool first = true;
|
||||
foreach (var prop in rec.GetType().GetProperties(
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = prop.GetValue(rec);
|
||||
string vs = v?.ToString() ?? "<null>";
|
||||
if (vs.Length > 50) vs = vs.Substring(0, 47) + "...";
|
||||
if (!first) sb.Append(", ");
|
||||
sb.Append($"{prop.Name}={vs}");
|
||||
first = false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip failing accessors
|
||||
}
|
||||
}
|
||||
sb.Append(" }");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void LogIfInteresting(MSG m)
|
||||
{
|
||||
// Filter out the highest-volume noise (timer ticks, paint, mouse moves
|
||||
// from a desktop session). Keep WM_USER..WM_APP+ entirely; those are
|
||||
// the candidates for the AVEVA-registered message.
|
||||
const uint WM_PAINT = 0x000F;
|
||||
const uint WM_TIMER = 0x0113;
|
||||
const uint WM_MOUSEMOVE = 0x0200;
|
||||
const uint WM_NCMOUSEMOVE = 0x00A0;
|
||||
if (m.message == WM_PAINT || m.message == WM_TIMER ||
|
||||
m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string interpreted = InterpretMessageId(m.message);
|
||||
Log(string.Format(
|
||||
"WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}",
|
||||
m.message, interpreted,
|
||||
m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF,
|
||||
m.hwnd.ToInt64()));
|
||||
}
|
||||
|
||||
private static string InterpretMessageId(uint id)
|
||||
{
|
||||
if (id < 0x0400) return "WM_<system>";
|
||||
if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}";
|
||||
if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}";
|
||||
return $"RegisterWindowMessage_0x{id:X4}";
|
||||
}
|
||||
|
||||
private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// Log every WM that lands on the probe window itself.
|
||||
string interpreted = InterpretMessageId(msg);
|
||||
Log(string.Format(
|
||||
"WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}",
|
||||
msg, interpreted,
|
||||
wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF));
|
||||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (wndProcHandle.IsAllocated) wndProcHandle.Free();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Live dev-rig smoke test for the alarms-over-gateway pipeline.
|
||||
/// Exercises <see cref="WnWrapAlarmConsumer"/> + <see cref="AlarmDispatcher"/> +
|
||||
/// <see cref="MxAccessAlarmEventSink"/> end-to-end against the actual
|
||||
/// AVEVA System Platform install: subscribes to
|
||||
/// <c>\\<machine>\Galaxy!DEV</c>, waits for at least one alarm
|
||||
/// transition (the dev rig's flip script writes
|
||||
/// <c>TestMachine_001.TestAlarm001</c> every 10s), drains the proto
|
||||
/// <c>OnAlarmTransitionEvent</c> from the queue, then ack-by-name's
|
||||
/// it and verifies the ack registers as a subsequent
|
||||
/// <see cref="AlarmTransitionKind.Acknowledge"/> transition.
|
||||
///
|
||||
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with the flip
|
||||
/// script running.
|
||||
/// </summary>
|
||||
public sealed class AlarmsLiveSmokeTests
|
||||
{
|
||||
private static readonly string SubscriptionExpression =
|
||||
$@"\\{Environment.MachineName}\Galaxy!DEV";
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(45);
|
||||
private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private const string SessionId = "alarms-live-smoke";
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
|
||||
public AlarmsLiveSmokeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
|
||||
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
|
||||
{
|
||||
Exception? threadException = null;
|
||||
var done = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { RunSmoke(); }
|
||||
catch (Exception ex) { threadException = ex; }
|
||||
finally { done.Set(); }
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
done.Wait();
|
||||
thread.Join();
|
||||
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunSmoke()
|
||||
{
|
||||
Log($"Subscription expression: {SubscriptionExpression}");
|
||||
Log($"Pump duration: {PumpDuration.TotalSeconds:F0}s; transition wait timeout: {TransitionWaitTimeout.TotalSeconds:F0}s");
|
||||
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
// The consumer owns no internal timer; we drive PollOnce manually
|
||||
// from the STA below (the wnwrap COM is ThreadingModel=Apartment,
|
||||
// and this test doesn't run a Win32 message pump on its STA).
|
||||
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
|
||||
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
|
||||
maxAlarmsPerFetch: 1024);
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
||||
|
||||
Log("Constructed consumer + sink + dispatcher.");
|
||||
dispatcher.Subscribe(SubscriptionExpression);
|
||||
Log("Subscribe -> ok. Driving PollOnce manually from this STA...");
|
||||
|
||||
// The wnwrap COM object is ThreadingModel=Apartment. The consumer
|
||||
// owns no internal timer, so we drive PollOnce manually here on the
|
||||
// STA. Production hosting routes polls through the worker's
|
||||
// StaRuntime.
|
||||
|
||||
// 1. Wait for the first transition (any kind), then keep waiting
|
||||
// for one with kind=Raise so the alarm is currently Active when
|
||||
// we try to ack. AVEVA rejects acks of cleared alarms with -55,
|
||||
// so we have to time the ack against the flip script's 10s
|
||||
// cadence.
|
||||
OnAlarmTransitionEvent? raiseBody = null;
|
||||
DateTime raiseDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
||||
while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
|
||||
{
|
||||
WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
|
||||
if (evt is null) break;
|
||||
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
|
||||
Log("Transition: " + DescribeTransition(body));
|
||||
Assert.Equal(SessionId, evt.Event.SessionId);
|
||||
if (body.TransitionKind == AlarmTransitionKind.Raise)
|
||||
{
|
||||
raiseBody = body;
|
||||
}
|
||||
}
|
||||
Assert.NotNull(raiseBody);
|
||||
Assert.False(string.IsNullOrEmpty(raiseBody!.AlarmFullReference));
|
||||
Assert.Contains("Galaxy", raiseBody.AlarmFullReference);
|
||||
|
||||
// 2. Snapshot the active set + verify the captured alarm is there.
|
||||
var snapshot = dispatcher.SnapshotActiveAlarms();
|
||||
Log($"SnapshotActiveAlarms count={snapshot.Count}");
|
||||
foreach (var s in snapshot)
|
||||
{
|
||||
Log(" active: " + DescribeSnapshot(s));
|
||||
}
|
||||
Assert.NotEmpty(snapshot);
|
||||
Assert.Contains(snapshot, s => s.AlarmFullReference == raiseBody.AlarmFullReference);
|
||||
|
||||
// 3. Ack-by-name using the captured reference. Parse the reference
|
||||
// via the same convention the gateway dispatcher uses
|
||||
// (Provider!Group.Tag where the tag may contain dots).
|
||||
Assert.True(TryParseReference(
|
||||
raiseBody.AlarmFullReference,
|
||||
out string provider, out string group, out string alarmName),
|
||||
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
|
||||
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
|
||||
|
||||
// Try the ack with real Windows identity. AVEVA's AlarmAckByName
|
||||
// may reject synthetic operator strings; using the current process
|
||||
// identity gives the alarm-history a recognizable principal.
|
||||
string realUser = Environment.UserName;
|
||||
string realNode = Environment.MachineName;
|
||||
string realDomain = Environment.UserDomainName ?? string.Empty;
|
||||
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
|
||||
|
||||
int rc = dispatcher.AcknowledgeByName(
|
||||
alarmName: alarmName,
|
||||
providerName: provider,
|
||||
groupName: group,
|
||||
ackComment: "alarms-live-smoke ack",
|
||||
ackOperatorName: realUser,
|
||||
ackOperatorNode: realNode,
|
||||
ackOperatorDomain: realDomain,
|
||||
ackOperatorFullName: realUser);
|
||||
Log($"AcknowledgeByName(real identity) -> rc={rc}");
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
|
||||
// 4. Wait for the post-ack transition. With the alarm flipping every
|
||||
// 10s and the consumer polling every 500ms, the next state
|
||||
// change should be either kind=Acknowledge (the ack we just
|
||||
// sent registered as a state delta UnackAlm → AckAlm) or the
|
||||
// flip script's next Clear (UnackAlm → UnackRtn).
|
||||
WorkerEvent? second = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
|
||||
Assert.NotNull(second);
|
||||
OnAlarmTransitionEvent secondBody = second!.Event.OnAlarmTransition;
|
||||
Log("Post-ack transition: " + DescribeTransition(secondBody));
|
||||
Assert.NotEqual(AlarmTransitionKind.Unspecified, secondBody.TransitionKind);
|
||||
|
||||
// 5. Pump a little longer to confirm the consumer keeps reporting
|
||||
// transitions on the 10s flip cadence.
|
||||
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
||||
int additional = 0;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
consumer.PollOnce();
|
||||
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
|
||||
{
|
||||
additional++;
|
||||
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
|
||||
Log($" +{additional}: " + DescribeTransition(body));
|
||||
}
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
Log($"Pump completed; additional transitions captured: {additional}.");
|
||||
}
|
||||
|
||||
private WorkerEvent? WaitForTransition(
|
||||
MxAccessEventQueue queue,
|
||||
TimeSpan timeout,
|
||||
string label,
|
||||
WnWrapAlarmConsumer consumer)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
int pollCount = 0;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
consumer.PollOnce();
|
||||
pollCount++;
|
||||
if (pollCount == 1) Log("First PollOnce returned without throw.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
|
||||
if (ex is System.Runtime.InteropServices.COMException ce)
|
||||
{
|
||||
Log($" HResult=0x{(uint)ce.HResult:X8}");
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
|
||||
{
|
||||
if (evt.Event.Family == MxEventFamily.OnAlarmTransition)
|
||||
{
|
||||
return evt;
|
||||
}
|
||||
Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
|
||||
}
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (poll count={pollCount}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseReference(
|
||||
string reference,
|
||||
out string provider,
|
||||
out string group,
|
||||
out string alarmName)
|
||||
{
|
||||
provider = group = alarmName = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(reference)) return false;
|
||||
int bang = reference.IndexOf('!');
|
||||
if (bang <= 0 || bang == reference.Length - 1) return false;
|
||||
string left = reference.Substring(0, bang);
|
||||
string right = reference.Substring(bang + 1);
|
||||
int dot = right.IndexOf('.');
|
||||
if (dot <= 0 || dot == right.Length - 1) return false;
|
||||
provider = left;
|
||||
group = right.Substring(0, dot);
|
||||
alarmName = right.Substring(dot + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string DescribeTransition(OnAlarmTransitionEvent body)
|
||||
{
|
||||
return string.Format(
|
||||
"kind={0} ref='{1}' source='{2}' type='{3}' severity={4} operator='{5}' comment='{6}' ts={7:o}",
|
||||
body.TransitionKind, body.AlarmFullReference, body.SourceObjectReference,
|
||||
body.AlarmTypeName, body.Severity, body.OperatorUser, body.OperatorComment,
|
||||
body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
private static string DescribeSnapshot(ActiveAlarmSnapshot s)
|
||||
{
|
||||
return string.Format(
|
||||
"ref='{0}' state={1} severity={2} operator='{3}' comment='{4}' ts={5:o}",
|
||||
s.AlarmFullReference, s.CurrentState, s.Severity, s.OperatorUser,
|
||||
s.OperatorComment,
|
||||
s.LastTransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using WNWRAPCONSUMERLib;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
|
||||
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
|
||||
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
|
||||
/// subscribe to the dev rig's `\\<machine>\Galaxy!DEV` provider, and
|
||||
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
|
||||
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
|
||||
/// the FILETIME→DateTime auto-marshaling that crashes
|
||||
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
|
||||
///
|
||||
/// Skip-gated; flip Skip=null to run on the dev rig.
|
||||
/// </summary>
|
||||
public sealed class WnWrapConsumerProbeTests
|
||||
{
|
||||
private static readonly string MachineName = Environment.MachineName;
|
||||
private static readonly string SubscriptionExpression =
|
||||
$@"\\{MachineName}\Galaxy!DEV";
|
||||
|
||||
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
|
||||
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
|
||||
private static readonly string XmlAlarmQuery =
|
||||
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
|
||||
"<QUERY>" +
|
||||
$"<NODE>{Environment.MachineName}</NODE>" +
|
||||
"<PROVIDER>Galaxy</PROVIDER>" +
|
||||
"<GROUP>DEV</GROUP>" +
|
||||
"</QUERY>" +
|
||||
"</QUERIES>";
|
||||
|
||||
private const int MaxAlarmsPerFetch = 100;
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
|
||||
public WnWrapConsumerProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
|
||||
public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream()
|
||||
{
|
||||
Exception? threadException = null;
|
||||
var done = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { RunProbe(); }
|
||||
catch (Exception ex) { threadException = ex; }
|
||||
finally { done.Set(); }
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
done.Wait();
|
||||
thread.Join();
|
||||
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
wwAlarmConsumerClass? client = null;
|
||||
try
|
||||
{
|
||||
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
|
||||
client = new wwAlarmConsumerClass();
|
||||
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
|
||||
|
||||
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
|
||||
// MUST precede RegisterConsumer for the alarm provider to become
|
||||
// visible. The wnwrap surface mirrors that requirement.
|
||||
try
|
||||
{
|
||||
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
|
||||
Log($"InitializeConsumer -> {init}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// hWnd=0 — XML pull-based; no message pump needed.
|
||||
int reg = client.RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: "MxGatewayProbe",
|
||||
szApplicationName: "MxGatewayProbe.WnWrap",
|
||||
szVersion: "1.0");
|
||||
Log($"RegisterConsumer(hWnd=0) -> {reg}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Try both subscription mechanisms: classic Subscribe (canonical
|
||||
// scope from prior aaAlarmManagedClient probe), and
|
||||
// SetXmlAlarmQuery (the wnwrap-native filter format).
|
||||
try
|
||||
{
|
||||
int sub = client.Subscribe(
|
||||
szSubscription: SubscriptionExpression,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
|
||||
client.SetXmlAlarmQuery(XmlAlarmQuery);
|
||||
Log("SetXmlAlarmQuery -> ok");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Echo the query back so we can confirm what the consumer is
|
||||
// actually filtering on (provider may rewrite or reject some
|
||||
// attributes silently).
|
||||
try
|
||||
{
|
||||
object echo = string.Empty;
|
||||
client.GetXmlAlarmQuery(out echo);
|
||||
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
|
||||
// every change in payload. Run for PumpDuration. The user's flip
|
||||
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
|
||||
// least 2-3 transitions over a 30s window.
|
||||
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
|
||||
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
||||
DateTime nextPoll = DateTime.UtcNow;
|
||||
int pollCount = 0;
|
||||
string lastV2 = string.Empty;
|
||||
string lastV1 = string.Empty;
|
||||
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
|
||||
int statsOk = 0, statsThrow = 0;
|
||||
string lastStats = string.Empty;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DateTime.UtcNow >= nextPoll)
|
||||
{
|
||||
pollCount++;
|
||||
|
||||
// V2 channel.
|
||||
try
|
||||
{
|
||||
object xml2 = string.Empty;
|
||||
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
|
||||
v2Ok++;
|
||||
string s = xml2?.ToString() ?? "<null>";
|
||||
if (s != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV2 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v2Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
|
||||
lastV2 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// V1 channel — different vtable slot; either may be the
|
||||
// populated one in this AVEVA build.
|
||||
try
|
||||
{
|
||||
object xml1 = string.Empty;
|
||||
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
|
||||
v1Ok++;
|
||||
string s = xml1?.ToString() ?? "<null>";
|
||||
if (s != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV1 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v1Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
|
||||
lastV1 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats channel — heartbeat + active-count even if the XML
|
||||
// calls are dry, this surfaces whether wnwrap sees any
|
||||
// alarms in the subscribed scope at all.
|
||||
try
|
||||
{
|
||||
int pct, total, active, newAlms, changes;
|
||||
client.GetStatistics(
|
||||
out pct, out total, out active, out newAlms, out changes,
|
||||
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
statsOk++;
|
||||
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
|
||||
if (statsSummary != lastStats)
|
||||
{
|
||||
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
|
||||
lastStats = statsSummary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statsThrow++;
|
||||
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
nextPoll = DateTime.UtcNow + PollInterval;
|
||||
}
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
|
||||
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
|
||||
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client != null && Marshal.IsComObject(client))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
|
||||
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.ProjectStructure;
|
||||
|
||||
public sealed class WorkerProjectReferenceTests
|
||||
{
|
||||
/// <summary>Verifies that the worker project targets .NET Framework 4.8 with x86 platform.</summary>
|
||||
[Fact]
|
||||
public void WorkerProject_TargetsNet48AndX86()
|
||||
{
|
||||
XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Worker");
|
||||
|
||||
Assert.Equal("net48", ElementValue(project, "TargetFramework"));
|
||||
Assert.Equal("x86", ElementValue(project, "PlatformTarget"));
|
||||
Assert.Equal("true", ElementValue(project, "Prefer32Bit"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the worker test project targets .NET Framework 4.8 with x86 platform.</summary>
|
||||
[Fact]
|
||||
public void WorkerTestProject_TargetsNet48AndX86()
|
||||
{
|
||||
XDocument project = LoadProject("ZB.MOM.WW.MxGateway.Worker.Tests");
|
||||
|
||||
Assert.Equal("net48", ElementValue(project, "TargetFramework"));
|
||||
Assert.Equal("x86", ElementValue(project, "PlatformTarget"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the MXAccess COM interop is referenced only by the
|
||||
/// worker project and its test project — never by the gateway server
|
||||
/// or the contracts project. The gateway must never load MXAccess COM
|
||||
/// directly (see <c>gateway.md</c>); the worker test project
|
||||
/// legitimately references the interop so it can exercise the
|
||||
/// COM-facing worker code (e.g. <c>WnWrapAlarmConsumer</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxAccessInteropReference_ExistsOnlyInWorkerAndWorkerTestProjects()
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string[] projectFiles = Directory.GetFiles(repositoryRoot.FullName, "*.csproj", SearchOption.AllDirectories)
|
||||
.Where(path => path.IndexOf($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
.Where(path => path.IndexOf($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<string> projectsWithMxAccessReference = projectFiles
|
||||
.Where(ProjectReferencesMxAccess)
|
||||
.Select(path => Path.GetFileNameWithoutExtension(path))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
["ZB.MOM.WW.MxGateway.Worker", "ZB.MOM.WW.MxGateway.Worker.Tests"],
|
||||
projectsWithMxAccessReference);
|
||||
}
|
||||
|
||||
private static bool ProjectReferencesMxAccess(string projectPath)
|
||||
{
|
||||
XDocument project = XDocument.Load(projectPath);
|
||||
|
||||
return project
|
||||
.Descendants()
|
||||
.Where(element => element.Name.LocalName is "Reference" or "COMReference" or "COMFileReference" or "PackageReference")
|
||||
.Select(element => (string?)element.Attribute("Include") ?? string.Empty)
|
||||
.Concat(project.Descendants().Where(element => element.Name.LocalName == "HintPath").Select(element => element.Value))
|
||||
.Any(reference =>
|
||||
reference.IndexOf("MxAccess", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| reference.IndexOf("ArchestrA.MXAccess", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| reference.IndexOf("LMXProxy", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
private static XDocument LoadProject(string projectName)
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string projectPath = Path.Combine(repositoryRoot.FullName, projectName, $"{projectName}.csproj");
|
||||
|
||||
return XDocument.Load(projectPath);
|
||||
}
|
||||
|
||||
private static string ElementValue(XDocument project, string elementName)
|
||||
{
|
||||
return project
|
||||
.Descendants()
|
||||
.Single(element => element.Name.LocalName == elementName)
|
||||
.Value;
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for StaCommandDispatcher command queueing and execution.
|
||||
/// </summary>
|
||||
public sealed class StaCommandDispatcherTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies commands execute on the STA thread in queue order.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ExecutesCommandsOnStaInQueueOrder()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
RecordingCommandExecutor executor = new();
|
||||
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||
|
||||
Task<MxCommandReply> first = dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||
Task<MxCommandReply> second = dispatcher.DispatchAsync(CreateCommand("correlation-2", MxCommandKind.AddItem));
|
||||
|
||||
MxCommandReply[] replies = await Task.WhenAll(first, second);
|
||||
|
||||
Assert.Equal(new[] { "correlation-1", "correlation-2" }, executor.CorrelationIds);
|
||||
Assert.All(executor.ThreadIds, threadId => Assert.Equal(runtime.StaThreadId, threadId));
|
||||
Assert.Equal("correlation-1", replies[0].CorrelationId);
|
||||
Assert.Equal("correlation-2", replies[1].CorrelationId);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, replies[0].ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies executor exceptions are captured as HResult in the reply without exposing message details.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenExecutorThrows_ReturnsFailureReplyWithHResult()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
StaCommandDispatcher dispatcher = new(
|
||||
runtime,
|
||||
new ThrowingCommandExecutor(new COMException("provider detail", unchecked((int)0x80070057))));
|
||||
|
||||
MxCommandReply reply = await dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||
|
||||
Assert.Equal("session-1", reply.SessionId);
|
||||
Assert.Equal("correlation-1", reply.CorrelationId);
|
||||
Assert.Equal(MxCommandKind.Register, reply.Kind);
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(unchecked((int)0x80070057), reply.Hresult);
|
||||
Assert.Contains("0x80070057", reply.DiagnosticMessage);
|
||||
Assert.DoesNotContain("provider detail", reply.DiagnosticMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cancellation before execution prevents the command from running.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenCanceledBeforeExecution_ReturnsCanceledReplyWithoutExecuting()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
BlockingCommandExecutor executor = new();
|
||||
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||
Task<MxCommandReply> blocked = dispatcher.DispatchAsync(CreateCommand("blocked", MxCommandKind.Register));
|
||||
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
using CancellationTokenSource cancellation = new();
|
||||
Task<MxCommandReply> canceled = dispatcher.DispatchAsync(
|
||||
CreateCommand("canceled", MxCommandKind.AddItem, cancellation.Token));
|
||||
cancellation.Cancel();
|
||||
|
||||
executor.Release();
|
||||
MxCommandReply canceledReply = await canceled;
|
||||
await blocked;
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Canceled, canceledReply.ProtocolStatus.Code);
|
||||
Assert.DoesNotContain("canceled", executor.CorrelationIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cancellation cannot abort a command already executing on the STA:
|
||||
/// once the executor has started, cancelling the token is a no-op and the
|
||||
/// command still runs to completion and returns its normal reply. This
|
||||
/// matches <c>gateway.md</c>: cancellation "cannot safely abort an in-flight
|
||||
/// COM call on the STA". The test does not — and cannot — distinguish "cancel
|
||||
/// observed and ignored" from "cancel never checked"; it only proves the
|
||||
/// in-flight command is not aborted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenCanceledWhileExecuting_DoesNotAbortInFlightCommand()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
BlockingCommandExecutor executor = new();
|
||||
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||
using CancellationTokenSource cancellation = new();
|
||||
|
||||
Task<MxCommandReply> replyTask = dispatcher.DispatchAsync(
|
||||
CreateCommand("late-reply", MxCommandKind.Register, cancellation.Token));
|
||||
|
||||
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||
cancellation.Cancel();
|
||||
executor.Release();
|
||||
|
||||
MxCommandReply reply = await replyTask;
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Contains("late-reply", executor.CorrelationIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies shutdown rejects new dispatch attempts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenShutdownRequested_RejectsNewCommands()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
StaCommandDispatcher dispatcher = new(runtime, new RecordingCommandExecutor());
|
||||
|
||||
dispatcher.RequestShutdown();
|
||||
MxCommandReply reply = await dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, reply.ProtocolStatus.Code);
|
||||
Assert.Equal("correlation-1", reply.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies shutdown allows the current command to complete but rejects queued commands.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestShutdown_RejectsQueuedCommandButLetsCurrentCommandFinish()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
BlockingCommandExecutor executor = new();
|
||||
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||
Task<MxCommandReply> current = dispatcher.DispatchAsync(CreateCommand("current", MxCommandKind.Register));
|
||||
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||
Task<MxCommandReply> pending = dispatcher.DispatchAsync(CreateCommand("pending", MxCommandKind.AddItem));
|
||||
|
||||
dispatcher.RequestShutdown();
|
||||
MxCommandReply pendingReply = await pending;
|
||||
executor.Release();
|
||||
MxCommandReply currentReply = await current;
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, pendingReply.ProtocolStatus.Code);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, currentReply.ProtocolStatus.Code);
|
||||
Assert.Equal(new[] { "current" }, executor.CorrelationIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies heartbeat reports current command correlation ID and pending command count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount()
|
||||
{
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
runtime.Start();
|
||||
BlockingCommandExecutor executor = new();
|
||||
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||
|
||||
Task<MxCommandReply> current = dispatcher.DispatchAsync(CreateCommand("current", MxCommandKind.Register));
|
||||
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||
Task<MxCommandReply> pending = dispatcher.DispatchAsync(CreateCommand("pending", MxCommandKind.AddItem));
|
||||
|
||||
WorkerHeartbeat heartbeat = new();
|
||||
dispatcher.PopulateHeartbeat(heartbeat);
|
||||
|
||||
Assert.Equal("current", heartbeat.CurrentCommandCorrelationId);
|
||||
Assert.Equal(1u, heartbeat.PendingCommandCount);
|
||||
|
||||
executor.Release();
|
||||
await Task.WhenAll(current, pending);
|
||||
}
|
||||
|
||||
private static StaCommand CreateCommand(
|
||||
string correlationId,
|
||||
MxCommandKind kind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = kind,
|
||||
Ping = new PingCommand
|
||||
{
|
||||
Message = correlationId,
|
||||
},
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static StaRuntime CreateRuntime()
|
||||
{
|
||||
return new StaRuntime(
|
||||
new NoopComApartmentInitializer(),
|
||||
new StaMessagePump(),
|
||||
TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test executor that records executed command correlations and thread IDs.
|
||||
/// </summary>
|
||||
private sealed class RecordingCommandExecutor : IStaCommandExecutor
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<string> correlationIds = new();
|
||||
private readonly List<int> threadIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of correlation IDs from executed commands.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CorrelationIds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return correlationIds.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List of thread IDs on which commands executed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> ThreadIds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return threadIds.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MxCommandReply Execute(StaCommand command)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
correlationIds.Add(command.CorrelationId);
|
||||
threadIds.Add(Thread.CurrentThread.ManagedThreadId);
|
||||
}
|
||||
|
||||
return new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test executor that blocks execution until explicitly released.
|
||||
/// </summary>
|
||||
private sealed class BlockingCommandExecutor : IStaCommandExecutor
|
||||
{
|
||||
private readonly ManualResetEventSlim release = new(false);
|
||||
private readonly object gate = new();
|
||||
private readonly List<string> correlationIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signals when execution of the current command has started.
|
||||
/// </summary>
|
||||
public ManualResetEventSlim Started { get; } = new(false);
|
||||
|
||||
/// <summary>
|
||||
/// List of correlation IDs from executed commands.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CorrelationIds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return correlationIds.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MxCommandReply Execute(StaCommand command)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
correlationIds.Add(command.CorrelationId);
|
||||
}
|
||||
|
||||
Started.Set();
|
||||
release.Wait(TimeSpan.FromSeconds(5));
|
||||
|
||||
return new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unblocks the waiting command execution.
|
||||
/// </summary>
|
||||
public void Release()
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test executor that always throws a configured exception.
|
||||
/// </summary>
|
||||
private sealed class ThrowingCommandExecutor : IStaCommandExecutor
|
||||
{
|
||||
private readonly Exception exception;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes with the exception to throw.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception to throw on execution.</param>
|
||||
public ThrowingCommandExecutor(Exception exception)
|
||||
{
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MxCommandReply Execute(StaCommand command)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="StaMessagePump"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Boundary: the <c>MsgWaitFailed</c> failure branch of <c>WaitForWorkOrMessages</c>
|
||||
/// is not exercised. Forcing <c>MsgWaitForMultipleObjectsEx</c> to fail requires
|
||||
/// passing a deliberately invalid native handle, which is unsafe to construct in a
|
||||
/// managed test and can corrupt the thread's wait state. The other behavior — null
|
||||
/// argument validation, waking on a signalled event, returning on timeout, the
|
||||
/// timeout conversion edge cases observable through wait latency, and the
|
||||
/// pump's drain count — is covered directly here.
|
||||
/// </remarks>
|
||||
public sealed class StaMessagePumpTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that WaitForWorkOrMessages throws ArgumentNullException for a null wake event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForWorkOrMessages_NullWakeEvent_ThrowsArgumentNullException()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(
|
||||
() => pump.WaitForWorkOrMessages(null!, TimeSpan.FromMilliseconds(10)));
|
||||
|
||||
Assert.Equal("commandWakeEvent", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WaitForWorkOrMessages returns promptly when the wake event is already signalled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WaitForWorkOrMessages_WakeEventAlreadySignalled_ReturnsImmediately()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
using ManualResetEventSlim wakeEvent = new(initialState: true);
|
||||
|
||||
await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30));
|
||||
stopwatch.Stop();
|
||||
|
||||
// A 30s timeout was supplied; returning quickly proves the signalled
|
||||
// wake handle — not the timeout — ended the wait.
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
|
||||
$"Wait took {stopwatch.Elapsed}; a pre-signalled wake event should return immediately.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WaitForWorkOrMessages wakes when the wake event is signalled from another thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WaitForWorkOrMessages_WakeEventSignalledDuringWait_Returns()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
using ManualResetEventSlim wakeEvent = new(initialState: false);
|
||||
|
||||
Task signalTask = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(150, CancellationToken.None);
|
||||
wakeEvent.Set();
|
||||
});
|
||||
|
||||
await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30));
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(10),
|
||||
$"Wait took {stopwatch.Elapsed}; signalling the wake event should end the 30s wait early.");
|
||||
});
|
||||
|
||||
await signalTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WaitForWorkOrMessages returns on timeout when the wake event is never signalled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WaitForWorkOrMessages_WakeEventNeverSignalled_ReturnsAfterTimeout()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
using ManualResetEventSlim wakeEvent = new(initialState: false);
|
||||
|
||||
await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromMilliseconds(150));
|
||||
stopwatch.Stop();
|
||||
|
||||
// The wait must end of its own accord (timeout). Lower bound is loose
|
||||
// because the timeout converts via Math.Ceiling and the OS scheduler
|
||||
// adds slack; upper bound proves it is not waiting indefinitely.
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(10),
|
||||
$"Wait took {stopwatch.Elapsed}; a 150ms timeout should end the wait without a signal.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a zero timeout (the TimeSpan.Zero conversion branch) returns without blocking.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WaitForWorkOrMessages_ZeroTimeout_ReturnsWithoutBlocking()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
using ManualResetEventSlim wakeEvent = new(initialState: false);
|
||||
|
||||
await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// TimeSpan.Zero exercises the "<= Zero -> 0 ms" conversion branch:
|
||||
// MsgWaitForMultipleObjectsEx polls and returns immediately.
|
||||
pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.Zero);
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"Wait took {stopwatch.Elapsed}; a zero timeout must not block.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PumpPendingMessages returns zero when the STA thread message queue is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PumpPendingMessages_NoMessagesPosted_ReturnsZero()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
|
||||
int pumped = await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
// Drain anything the apartment/thread start posted, then measure a clean queue.
|
||||
pump.PumpPendingMessages();
|
||||
return pump.PumpPendingMessages();
|
||||
});
|
||||
|
||||
Assert.Equal(0, pumped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PumpPendingMessages dispatches and counts messages posted to the STA thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PumpPendingMessages_MessagesPostedToStaThread_ReturnsCountProcessed()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
|
||||
int pumped = await RunOnStaThreadAsync(() =>
|
||||
{
|
||||
// Clear any startup messages so the count reflects only what we post.
|
||||
pump.PumpPendingMessages();
|
||||
|
||||
uint threadId = GetCurrentThreadId();
|
||||
Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero));
|
||||
Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero));
|
||||
Assert.True(PostThreadMessage(threadId, WmNull, UIntPtr.Zero, IntPtr.Zero));
|
||||
|
||||
return pump.PumpPendingMessages();
|
||||
});
|
||||
|
||||
Assert.Equal(3, pumped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WaitForWorkOrMessages returns once a Windows message is posted to the STA thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WaitForWorkOrMessages_WindowsMessagePosted_ReturnsForInputAvailable()
|
||||
{
|
||||
StaMessagePump pump = new();
|
||||
using ManualResetEventSlim wakeEvent = new(initialState: false);
|
||||
using ManualResetEventSlim threadReady = new(initialState: false);
|
||||
uint staThreadId = 0;
|
||||
|
||||
Task staTask = RunOnStaThreadAsync(() =>
|
||||
{
|
||||
staThreadId = GetCurrentThreadId();
|
||||
pump.PumpPendingMessages();
|
||||
threadReady.Set();
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
// The wake event is never signalled. Only the posted Windows message
|
||||
// (QS_ALLINPUT wake mask) can end this 30s wait early.
|
||||
pump.WaitForWorkOrMessages(wakeEvent.WaitHandle, TimeSpan.FromSeconds(30));
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.True(
|
||||
stopwatch.Elapsed < TimeSpan.FromSeconds(10),
|
||||
$"Wait took {stopwatch.Elapsed}; a posted Windows message should wake the pump.");
|
||||
});
|
||||
|
||||
Assert.True(threadReady.Wait(TimeSpan.FromSeconds(5)), "STA thread did not start.");
|
||||
await Task.Delay(100, CancellationToken.None);
|
||||
Assert.True(
|
||||
PostThreadMessage(staThreadId, WmNull, UIntPtr.Zero, IntPtr.Zero),
|
||||
"Failed to post a Windows message to the STA thread.");
|
||||
|
||||
await staTask;
|
||||
}
|
||||
|
||||
private const uint WmNull = 0x0000;
|
||||
|
||||
/// <summary>Runs an action on a dedicated STA thread and returns when it completes.</summary>
|
||||
private static Task RunOnStaThreadAsync(Action action)
|
||||
{
|
||||
return RunOnStaThreadAsync(() =>
|
||||
{
|
||||
action();
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Runs a function on a dedicated STA thread and returns its result.</summary>
|
||||
private static Task<T> RunOnStaThreadAsync<T>(Func<T> function)
|
||||
{
|
||||
TaskCompletionSource<T> completion = new();
|
||||
Thread thread = new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
completion.SetResult(function());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
completion.SetException(exception);
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool PostThreadMessage(
|
||||
uint threadId,
|
||||
uint message,
|
||||
UIntPtr wParam,
|
||||
IntPtr lParam);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;
|
||||
|
||||
public sealed class StaRuntimeTests
|
||||
{
|
||||
/// <summary>Verifies that InvokeAsync executes commands on the STA thread.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ExecutesCommandOnStaThread()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = CreateRuntime(initializer);
|
||||
|
||||
runtime.Start();
|
||||
|
||||
StaCommandObservation observation = await runtime.InvokeAsync(
|
||||
() => new StaCommandObservation(
|
||||
Thread.CurrentThread.ManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
|
||||
Assert.Equal(runtime.StaThreadId, observation.ThreadId);
|
||||
Assert.Equal(initializer.InitializeThreadId, observation.ThreadId);
|
||||
Assert.Equal(ApartmentState.STA, observation.ApartmentState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that InvokeAsync wakes the idle pump when a command is queued.
|
||||
/// The pump is configured with a 30-second idle period — far longer than
|
||||
/// any reasonable test run — so the awaited command completing at all proves
|
||||
/// the command wake event (not the idle pump tick) drove the dispatch. No
|
||||
/// wall-clock assertion is used: a loaded CI agent can stall an otherwise
|
||||
/// correct dispatch past an arbitrary millisecond budget, which would be a
|
||||
/// false failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WakesIdlePumpForQueuedCommand()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = new(
|
||||
initializer,
|
||||
new StaMessagePump(),
|
||||
TimeSpan.FromSeconds(30));
|
||||
runtime.Start();
|
||||
|
||||
int threadId = await runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId);
|
||||
|
||||
Assert.Equal(runtime.StaThreadId, threadId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Shutdown stops the thread and uninitializes the COM apartment.</summary>
|
||||
[Fact]
|
||||
public void Shutdown_StopsThreadAndUninitializesComApartment()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = CreateRuntime(initializer);
|
||||
runtime.Start();
|
||||
|
||||
bool stopped = runtime.Shutdown(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(stopped);
|
||||
Assert.False(runtime.IsRunning);
|
||||
Assert.Equal(1, initializer.InitializeCount);
|
||||
Assert.Equal(1, initializer.UninitializeCount);
|
||||
Assert.Equal(initializer.InitializeThreadId, initializer.UninitializeThreadId);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LastActivityUtc updates while the pump is idle.</summary>
|
||||
[Fact]
|
||||
public void LastActivityUtc_UpdatesWhilePumpIsIdle()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = CreateRuntime(initializer);
|
||||
runtime.Start();
|
||||
DateTimeOffset firstActivity = runtime.LastActivityUtc;
|
||||
|
||||
bool updated = SpinWait.SpinUntil(
|
||||
() => runtime.LastActivityUtc > firstActivity,
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(updated);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that InvokeAsync faults the returned task when a command raises an exception without stopping the runtime.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_CommandException_FaultsReturnedTaskWithoutStoppingRuntime()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = CreateRuntime(initializer);
|
||||
runtime.Start();
|
||||
|
||||
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => runtime.InvokeAsync<int>(() => throw new InvalidOperationException("command failed")));
|
||||
|
||||
int threadId = await runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId);
|
||||
Assert.Equal("command failed", exception.Message);
|
||||
Assert.Equal(runtime.StaThreadId, threadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that InvokeAsync returns a faulted task when called after
|
||||
/// Shutdown. Worker-016 introduced <see cref="StaRuntimeShutdownException"/>
|
||||
/// (a dedicated subtype of <see cref="InvalidOperationException"/>) so
|
||||
/// callers — notably <c>MxAccessStaSession.RunAlarmPollLoopAsync</c> —
|
||||
/// can distinguish the graceful shutdown signal from a vanilla
|
||||
/// <see cref="InvalidOperationException"/> such as an STA-affinity
|
||||
/// assertion. The test pins the exact type so a regression that
|
||||
/// reverts to a plain <c>InvalidOperationException</c> fails here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_AfterShutdown_ReturnsFaultedTask()
|
||||
{
|
||||
RecordingComApartmentInitializer initializer = new();
|
||||
using StaRuntime runtime = CreateRuntime(initializer);
|
||||
runtime.Start();
|
||||
runtime.Shutdown(TimeSpan.FromSeconds(2));
|
||||
|
||||
StaRuntimeShutdownException exception = await Assert.ThrowsAsync<StaRuntimeShutdownException>(
|
||||
() => runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId));
|
||||
|
||||
Assert.Contains("shutting down", exception.Message);
|
||||
}
|
||||
|
||||
private static StaRuntime CreateRuntime(RecordingComApartmentInitializer initializer)
|
||||
{
|
||||
return new StaRuntime(
|
||||
initializer,
|
||||
new StaMessagePump(),
|
||||
TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
/// <summary>Records the thread ID and apartment state of an STA command execution.</summary>
|
||||
private sealed class StaCommandObservation
|
||||
{
|
||||
/// <summary>Initializes a new instance of the StaCommandObservation class.</summary>
|
||||
/// <param name="threadId">Managed thread ID where the command executed.</param>
|
||||
/// <param name="apartmentState">COM apartment state of the thread.</param>
|
||||
public StaCommandObservation(int threadId, ApartmentState apartmentState)
|
||||
{
|
||||
ThreadId = threadId;
|
||||
ApartmentState = apartmentState;
|
||||
}
|
||||
|
||||
/// <summary>The thread ID where the command executed.</summary>
|
||||
public int ThreadId { get; }
|
||||
|
||||
/// <summary>The apartment state of the thread.</summary>
|
||||
public ApartmentState ApartmentState { get; }
|
||||
}
|
||||
|
||||
private sealed class RecordingComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <summary>The number of times Initialize was called.</summary>
|
||||
public int InitializeCount { get; private set; }
|
||||
|
||||
/// <summary>The number of times Uninitialize was called.</summary>
|
||||
public int UninitializeCount { get; private set; }
|
||||
|
||||
/// <summary>The thread ID where Initialize was called.</summary>
|
||||
public int? InitializeThreadId { get; private set; }
|
||||
|
||||
/// <summary>The thread ID where Uninitialize was called.</summary>
|
||||
public int? UninitializeThreadId { get; private set; }
|
||||
|
||||
/// <summary>Initializes the COM apartment and records the calling thread.</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
InitializeCount++;
|
||||
InitializeThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
}
|
||||
|
||||
/// <summary>Uninitializes the COM apartment and records the calling thread.</summary>
|
||||
public void Uninitialize()
|
||||
{
|
||||
UninitializeCount++;
|
||||
UninitializeThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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.Ipc;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Single configurable <see cref="IWorkerRuntimeSession"/> test double shared by
|
||||
/// the IPC tests. Replaces the two independent (and previously diverged)
|
||||
/// <c>FakeRuntimeSession</c> copies in WorkerPipeSessionTests and
|
||||
/// WorkerPipeClientTests: one supported dispatch blocking and event enqueue, the
|
||||
/// other did not. This consolidated double supports every configuration both
|
||||
/// call sites needed, so a minimal caller simply leaves the options unset.
|
||||
/// </summary>
|
||||
internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||
{
|
||||
private readonly ManualResetEventSlim releaseDispatch = new(false);
|
||||
private readonly object gate = new();
|
||||
private readonly Queue<WorkerEvent> events = new();
|
||||
private readonly List<string> cancelledCorrelationIds = new();
|
||||
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty);
|
||||
|
||||
/// <summary>Gets the event signaled when dispatch begins.</summary>
|
||||
public ManualResetEventSlim DispatchStarted { get; } = new(false);
|
||||
|
||||
/// <summary>Blocks dispatch execution until explicitly released.</summary>
|
||||
public bool BlockDispatch { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether to throw an exception after dispatch is released.</summary>
|
||||
public bool ThrowAfterDispatchReleased { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException.</summary>
|
||||
public bool ThrowTimeoutOnShutdown { get; set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether Dispose was called.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Starts the worker session with the given session ID and process ID.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="workerProcessId">The worker process ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready response.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new WorkerReady
|
||||
{
|
||||
WorkerProcessId = workerProcessId,
|
||||
MxaccessProgid = MxAccessInteropInfo.ProgId,
|
||||
MxaccessClsid = MxAccessInteropInfo.Clsid,
|
||||
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispatches a command to the STA thread.</summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>The command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
return Task.Run(
|
||||
() =>
|
||||
{
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
command.CorrelationId));
|
||||
DispatchStarted.Set();
|
||||
|
||||
if (BlockDispatch)
|
||||
{
|
||||
releaseDispatch.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty));
|
||||
|
||||
if (ThrowAfterDispatchReleased)
|
||||
{
|
||||
throw new InvalidOperationException("Command failed after shutdown started.");
|
||||
}
|
||||
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Captures current heartbeat snapshot.</summary>
|
||||
/// <returns>Current runtime heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains queued events up to the specified limit.</summary>
|
||||
/// <param name="maxEvents">Maximum events to drain; 0 drains all.</param>
|
||||
/// <returns>The drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
? events.Count
|
||||
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue());
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drains a pending fault if any.</summary>
|
||||
/// <returns>Pending fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of every correlation id passed to
|
||||
/// <see cref="CancelCommand"/>. Recording lets the IPC tests
|
||||
/// assert that a <c>WorkerCancel</c> envelope dispatched on the
|
||||
/// gateway side reaches the runtime session — see Worker.Tests-017.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> CancelledCorrelationIds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return new List<string>(cancelledCorrelationIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool cancelCommandReturnValue;
|
||||
|
||||
/// <summary>
|
||||
/// Optional return value yielded by <see cref="CancelCommand"/>.
|
||||
/// Defaults to <c>false</c> (the runtime had no matching in-flight
|
||||
/// command), matching the previous test-double behaviour. Mutated
|
||||
/// and read under <c>lock(gate)</c> to match the locking convention
|
||||
/// the rest of this fake uses for <c>cancelledCorrelationIds</c>,
|
||||
/// <c>snapshot</c>, and <c>events</c> (Worker.Tests-027).
|
||||
/// </summary>
|
||||
public bool CancelCommandReturnValue
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return cancelCommandReturnValue;
|
||||
}
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cancelCommandReturnValue = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancels command by correlation ID.</summary>
|
||||
/// <param name="correlationId">The command correlation ID.</param>
|
||||
/// <returns>True if cancelled; false otherwise.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
cancelledCorrelationIds.Add(correlationId);
|
||||
return cancelCommandReturnValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requests graceful shutdown.</summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Shuts down gracefully within the specified timeout.</summary>
|
||||
/// <param name="timeout">Shutdown timeout period.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result.</returns>
|
||||
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
if (ThrowTimeoutOnShutdown)
|
||||
{
|
||||
return Task.FromException<MxAccessShutdownResult>(
|
||||
new TimeoutException("Simulated graceful shutdown timeout."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
/// <summary>Releases a blocked dispatch.</summary>
|
||||
public void ReleaseDispatch()
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
}
|
||||
|
||||
/// <summary>Sets the current heartbeat snapshot.</summary>
|
||||
/// <param name="value">The snapshot to set.</param>
|
||||
public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enqueues a worker event to be drained.</summary>
|
||||
/// <param name="workerEvent">The event to enqueue.</param>
|
||||
public void EnqueueEvent(WorkerEvent workerEvent)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
events.Enqueue(workerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
releaseDispatch.Set();
|
||||
releaseDispatch.Dispose();
|
||||
DispatchStarted.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an xUnit test as requiring installed MXAccess COM and live
|
||||
/// provider state. When the opt-in environment variable named by
|
||||
/// <see cref="GatewayContractInfo.LiveMxAccessOptInVariableName"/> is
|
||||
/// not set to <c>1</c>, the test is reported as <c>Skipped</c> by
|
||||
/// xUnit rather than silently returning early (which xUnit would
|
||||
/// otherwise report as <c>Passed</c>). Mirrors
|
||||
/// <c>ZB.MOM.WW.MxGateway.IntegrationTests.LiveMxAccessFactAttribute</c>; both
|
||||
/// copies bind to the same <c>GatewayContractInfo</c> constant so the
|
||||
/// env-var name has a single literal source of truth (Worker.Tests-025).
|
||||
/// </summary>
|
||||
public sealed class LiveMxAccessFactAttribute : FactAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The environment variable that opts the suite into running live
|
||||
/// MXAccess COM tests. Must be set to <c>1</c> on a machine with the
|
||||
/// installed MXAccess runtime and a reachable Galaxy provider.
|
||||
/// Sourced from <see cref="GatewayContractInfo.LiveMxAccessOptInVariableName"/>
|
||||
/// so a single constant gates both Worker.Tests and IntegrationTests.
|
||||
/// </summary>
|
||||
public const string LiveMxAccessVariableName = GatewayContractInfo.LiveMxAccessOptInVariableName;
|
||||
|
||||
/// <summary>Initializes the attribute, skipping the test unless the env var is set.</summary>
|
||||
public LiveMxAccessFactAttribute()
|
||||
{
|
||||
if (!string.Equals(
|
||||
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Skip = $"Set {LiveMxAccessVariableName}=1 to run live MXAccess tests.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared no-operation <see cref="IStaComApartmentInitializer"/> for tests that
|
||||
/// construct an <see cref="StaRuntime"/> without a real COM apartment. Replaces
|
||||
/// the per-file copies that were previously defined independently in
|
||||
/// StaCommandDispatcherTests, MxAccessStaSessionTests, and MxAccessCommandExecutorTests.
|
||||
/// </summary>
|
||||
internal sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Uninitialize()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared no-operation <see cref="IMxAccessEventSink"/> for tests that construct
|
||||
/// an <see cref="MxAccessStaSession"/> but do not exercise the event sink.
|
||||
/// Replaces the per-file <c>NoopEventSink</c>/<c>NullEventSink</c> copies that
|
||||
/// were previously defined independently in MxAccessCommandExecutorTests and
|
||||
/// AlarmCommandExecutorTests.
|
||||
/// </summary>
|
||||
internal sealed class NoopEventSink : IMxAccessEventSink
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Attach(object mxAccessComObject, string sessionId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Detach()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared no-operation <see cref="IMxAccessServer"/> for tests that need to
|
||||
/// construct an <see cref="MxAccessSession"/> via
|
||||
/// <see cref="MxAccessSession.CreateForTesting"/> but do not exercise any
|
||||
/// MXAccess COM call. Replaces the per-file <c>NullMxAccessServer</c> copy
|
||||
/// that previously lived inside <c>AlarmCommandExecutorTests</c> and was
|
||||
/// constructed via reflection — see Worker.Tests-016 for the rationale.
|
||||
/// </summary>
|
||||
internal sealed class NoopMxAccessServer : IMxAccessServer
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public int Register(string clientName) => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AddItem(int serverHandle, string itemDefinition) => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnAdvise(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Suspend(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Activate(int serverHandle, int itemHandle)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(int serverHandle, int itemHandle, object? value, int userId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AuthenticateUser(string userName, string password) => 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int ArchestrAUserToId(string userName) => 0;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for building raw length-prefixed worker frames in tests.
|
||||
/// Replaces the per-file <c>CreateFrame</c>/<c>WriteUInt32LittleEndian</c> copies
|
||||
/// that were previously defined independently in WorkerFrameProtocolTests and
|
||||
/// WorkerPipeSessionTests.
|
||||
/// </summary>
|
||||
internal static class WorkerFrameTestHelpers
|
||||
{
|
||||
/// <summary>Builds a length-prefixed frame from a protobuf message.</summary>
|
||||
/// <param name="message">Message to serialize into the frame payload.</param>
|
||||
public static byte[] CreateFrame(IMessage message)
|
||||
{
|
||||
return CreateFrame(message.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>Builds a length-prefixed frame from a raw payload.</summary>
|
||||
/// <param name="payload">Payload bytes to wrap in a frame.</param>
|
||||
public static byte[] CreateFrame(byte[] payload)
|
||||
{
|
||||
byte[] frame = new byte[sizeof(uint) + payload.Length];
|
||||
WriteUInt32LittleEndian(frame, (uint)payload.Length);
|
||||
payload.CopyTo(frame, sizeof(uint));
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>Writes a little-endian unsigned 32-bit integer to the buffer head.</summary>
|
||||
/// <param name="buffer">Buffer to write into; must have at least four bytes.</param>
|
||||
/// <param name="value">Value to encode.</param>
|
||||
public static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="aaAlarmManagedClient">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="IAlarmMgrDataProvider">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Interop.WNWRAPCONSUMERLib">
|
||||
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user