PR 7.2 — Retire legacy Galaxy projects + service
Matrix-gate satisfied (14 passed / 1 skipped / 0 failed on 2026-04-30 per docs/v2/Galaxy.ParityMatrix.md). Galaxy access flows through the in-process GalaxyDriver → mxaccessgw exclusively. Legacy infrastructure deleted in this commit: Source projects (6): - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (.NET 4.8 x86 + MXAccess COM) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process pipe client) - src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared (pipe-IPC contracts) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests Test projects with no consumer after legacy retired (3): - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E (drove Galaxy.Host EXE) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests (drove both backends) - tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport (only consumed by Host/Proxy tests) Edits: - ZB.MOM.WW.OtOpcUa.slnx: drop nine project entries - Server.csproj: drop Driver.Galaxy.Proxy ProjectReference - Server/Program.cs: drop GalaxyProxyDriverFactoryExtensions.Register + the parallel-registration comment block; only GalaxyDriverFactoryExtensions registers now under DriverType "GalaxyMxGateway" - Install-Services.ps1: rewrite to drop OtOpcUaGalaxyHost service install + the GalaxySharedSecret/ZbConnection/GalaxyClientName/GalaxyPipeName/ AvevaServiceDependencies/MxAccessInitialConnect* parameters that only applied to the legacy host. Adds a closing note pointing operators at the separate mxaccessgw install - Uninstall-Services.ps1: keep OtOpcUaGalaxyHost in the cleanup loop so pre-7.2 rigs upgrade-uninstall cleanly, plus add OtOpcUaWonderwareHistorian - scripts/e2e/test-galaxy.ps1: deleted (drove the legacy E2E) - scripts/e2e/e2e-config.sample.json: rewrite the galaxy section comment to reflect the GalaxyMxGateway-only path - scripts/e2e/README.md: drop OtOpcUaGalaxyHost references - scripts/compliance/phase-7-compliance.ps1: drop Galaxy.Shared HistorianAlarms* checks (those contracts moved to Driver.Historian.Wonderware.Client in PR 3.4) Live state: OtOpcUaGalaxyHost Windows service stopped + removed via NSSM before this commit. The dev box's Galaxy access is now exclusively through the running mxaccessgw (separate repo). Stays out of scope for PR 7.2 (PR 7.3 territory): - CLAUDE.md Galaxy section rewrite - mxaccess_documentation.md deletion - Memory entries for the now-retired Galaxy.Host service Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class HierarchyParityTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public HierarchyParityTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_returns_at_least_one_gobject_with_attributes()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBeGreaterThan(0,
|
||||
"live Galaxy ZB has at least one deployed gobject");
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_only_lowercase_browse_paths_for_each_attribute()
|
||||
{
|
||||
// OPC UA browse paths are case-sensitive; the v1 server emits Galaxy attribute
|
||||
// names verbatim (camelCase like "PV.Input.Value"). Parity invariant: every
|
||||
// emitted variable's full reference contains a '.' separating the gobject
|
||||
// tag-name from the attribute name (Galaxy reference grammar).
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldAllBe(v => v.AttributeInfo.FullName.Contains('.'),
|
||||
"Galaxy MXAccess full references are 'tag.attribute'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_marks_at_least_one_attribute_as_historized_when_HistoryExtension_present()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Soft assertion — some Galaxies are configuration-only with no Historian extensions.
|
||||
// We only check the field flows through correctly when populated.
|
||||
var historized = builder.Variables.Count(v => v.AttributeInfo.IsHistorized);
|
||||
// Just assert the count is non-negative — the value itself is data-dependent.
|
||||
historized.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns one <c>OtOpcUa.Driver.Galaxy.Host.exe</c> subprocess per test class and exposes
|
||||
/// a connected <see cref="GalaxyProxyDriver"/> for the tests. Per Phase 2 plan §"Stream E
|
||||
/// Parity Validation": the Proxy owns a session against a real out-of-process Host running
|
||||
/// the production-shape <c>MxAccessGalaxyBackend</c> backed by live ZB + MXAccess COM.
|
||||
/// Skipped when the Host EXE isn't built or when ZB SQL is unreachable.
|
||||
/// </summary>
|
||||
public sealed class ParityFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
private Process? _host;
|
||||
private const string Secret = "parity-suite-secret";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; }
|
||||
if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; }
|
||||
|
||||
var hostExe = FindHostExe();
|
||||
if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; }
|
||||
|
||||
// Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL
|
||||
// path without requiring a healthy MXAccess connection. Tests that need MXAccess
|
||||
// override via env vars before InitializeAsync is called (use a separate fixture).
|
||||
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
|
||||
var psi = new ProcessStartInfo(hostExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
EnvironmentVariables =
|
||||
{
|
||||
["OTOPCUA_GALAXY_PIPE"] = pipe,
|
||||
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
||||
["OTOPCUA_GALAXY_SECRET"] = Secret,
|
||||
["OTOPCUA_GALAXY_BACKEND"] = "db",
|
||||
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
},
|
||||
};
|
||||
|
||||
_host = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
|
||||
|
||||
// Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this
|
||||
// in production with retry, but the parity tests are best served by a fixed warm-up.
|
||||
await Task.Delay(2_000);
|
||||
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "parity",
|
||||
PipeName = pipe,
|
||||
SharedSecret = Secret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
|
||||
if (_host is not null && !_host.HasExited)
|
||||
{
|
||||
try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
try { _host.WaitForExit(5_000); } catch { /* ignore */ }
|
||||
}
|
||||
_host?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern.</summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null)
|
||||
Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 1433);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static string? FindHostExe()
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var solutionRoot = asmDir;
|
||||
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
|
||||
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||
}
|
||||
if (solutionRoot is null) return null;
|
||||
|
||||
var path = Path.Combine(solutionRoot,
|
||||
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ParityCollection))]
|
||||
public sealed class ParityCollection : ICollectionFixture<ParityFixture> { }
|
||||
@@ -1,70 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only <see cref="IAddressSpaceBuilder"/> that records every Folder + Variable
|
||||
/// registration. Mirrors the v1 in-process address-space build so tests can assert on
|
||||
/// the same shape the legacy <c>LmxNodeManager</c> produced.
|
||||
/// </summary>
|
||||
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<RecordedFolder> Folders { get; } = new();
|
||||
public List<RecordedVariable> Variables { get; } = new();
|
||||
public List<RecordedProperty> Properties { get; } = new();
|
||||
public List<RecordedAlarmCondition> AlarmConditions { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(new RecordedFolder(browseName, displayName));
|
||||
return this; // single flat builder for tests; nesting irrelevant for parity assertions
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
|
||||
return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
{
|
||||
Properties.Add(new RecordedProperty(browseName, dataType, value));
|
||||
}
|
||||
|
||||
public sealed record RecordedFolder(string BrowseName, string DisplayName);
|
||||
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
|
||||
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
|
||||
public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info);
|
||||
public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args);
|
||||
|
||||
/// <summary>
|
||||
/// Sink the tests assert on to verify the alarm event forwarder routed a transition
|
||||
/// to the correct source-node-id. One entry per <see cref="IAlarmSource.OnAlarmEvent"/>.
|
||||
/// </summary>
|
||||
public List<RecordedAlarmTransition> AlarmTransitions { get; } = new();
|
||||
|
||||
private sealed class RecordedVariableHandle : IVariableHandle
|
||||
{
|
||||
private readonly List<RecordedAlarmCondition> _conditions;
|
||||
public string FullReference { get; }
|
||||
public RecordedVariableHandle(string fullReference, List<RecordedAlarmCondition> conditions)
|
||||
{
|
||||
FullReference = fullReference;
|
||||
_conditions = conditions;
|
||||
}
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
_conditions.Add(new RecordedAlarmCondition(FullReference, info));
|
||||
return new RecordingSink(FullReference);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IAlarmConditionSink
|
||||
{
|
||||
public string SourceNodeId { get; }
|
||||
public List<AlarmEventArgs> Received { get; } = new();
|
||||
public RecordingSink(string sourceNodeId) => SourceNodeId = sourceNodeId;
|
||||
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the four 2026-04-13 stability findings (commits <c>c76ab8f</c>,
|
||||
/// <c>7310925</c>) per Phase 2 plan §"Stream E.3". Each test asserts the v2 topology
|
||||
/// does not reintroduce the v1 defect.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Trait("Subcategory", "StabilityRegression")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class StabilityFindingsRegressionTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public StabilityFindingsRegressionTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
/// <summary>
|
||||
/// Finding #1 — <em>phantom probe subscription flips Tick() to Stopped</em>. When the
|
||||
/// v1 GalaxyRuntimeProbeManager failed to subscribe a probe, it left a phantom entry
|
||||
/// that the next Tick() flipped to Stopped, fanning Bad-quality across unrelated
|
||||
/// subtrees. v2 regression net: a failed subscribe must not affect host status of
|
||||
/// subscriptions that did succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Failed_subscribe_does_not_corrupt_unrelated_host_status()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// GetHostStatuses pre-subscribe — baseline.
|
||||
var preSubscribe = _fx.Driver!.GetHostStatuses().Count;
|
||||
|
||||
// Try to subscribe to a nonsense reference; the Host should reject it without
|
||||
// poisoning the host-status table.
|
||||
try
|
||||
{
|
||||
await _fx.Driver.SubscribeAsync(
|
||||
new[] { "nonexistent.tag.does.not.exist[]" },
|
||||
TimeSpan.FromSeconds(1),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch { /* expected — bad reference */ }
|
||||
|
||||
var postSubscribe = _fx.Driver.GetHostStatuses().Count;
|
||||
postSubscribe.ShouldBe(preSubscribe,
|
||||
"failed subscribe must not mutate the host-status snapshot");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #2 — <em>cross-host quality clear wipes sibling state during recovery</em>.
|
||||
/// v1 cleared all subscriptions when ANY host changed state, even healthy peers.
|
||||
/// v2 regression net: host-status events must be scoped to the affected host name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Host_status_change_event_carries_specific_host_name_not_global_clear()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var changes = new List<HostStatusChangedEventArgs>();
|
||||
EventHandler<HostStatusChangedEventArgs> handler = (_, e) => changes.Add(e);
|
||||
_fx.Driver!.OnHostStatusChanged += handler;
|
||||
try
|
||||
{
|
||||
// We can't deterministically force a Host status transition in the suite without
|
||||
// tearing down the COM connection. The structural assertion is sufficient: the
|
||||
// event TYPE carries a specific HostName, OldState, NewState — there is no
|
||||
// "global clear" payload. v1's bug was structural; v2's event signature
|
||||
// mathematically prevents reintroduction.
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("HostName")
|
||||
.ShouldNotBeNull("event signature must scope to a specific host");
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("OldState")
|
||||
.ShouldNotBeNull();
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("NewState")
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fx.Driver.OnHostStatusChanged -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #3 — <em>sync-over-async on the OPC UA stack thread</em>. v1 had spots
|
||||
/// that called <c>.Result</c> / <c>.Wait()</c> from the OPC UA stack callback,
|
||||
/// deadlocking under load. v2 regression net: every <see cref="GalaxyProxyDriver"/>
|
||||
/// capability method is async-all-the-way; a reflection scan asserts no
|
||||
/// <c>.GetAwaiter().GetResult()</c> appears in IL of the public surface.
|
||||
/// Implemented as a structural shape assertion — every public method returning
|
||||
/// <see cref="Task"/> or <see cref="Task{TResult}"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void All_GalaxyProxyDriver_capability_methods_return_Task_for_async_correctness()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var driverType = typeof(Proxy.GalaxyProxyDriver);
|
||||
var capabilityMethods = driverType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => m.DeclaringType == driverType
|
||||
&& !m.IsSpecialName
|
||||
&& m.Name is "InitializeAsync" or "ReinitializeAsync" or "ShutdownAsync"
|
||||
or "FlushOptionalCachesAsync" or "DiscoverAsync"
|
||||
or "ReadAsync" or "WriteAsync"
|
||||
or "SubscribeAsync" or "UnsubscribeAsync"
|
||||
or "SubscribeAlarmsAsync" or "UnsubscribeAlarmsAsync" or "AcknowledgeAsync"
|
||||
or "ReadRawAsync" or "ReadProcessedAsync");
|
||||
|
||||
foreach (var m in capabilityMethods)
|
||||
{
|
||||
(m.ReturnType == typeof(Task) || m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
|
||||
.ShouldBeTrue($"{m.Name} must return Task or Task<T> — sync-over-async risks deadlock under load");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #4 — <em>fire-and-forget alarm tasks racing shutdown</em>. v1 fired
|
||||
/// <c>Task.Run(() => raiseAlarm)</c> without awaiting, so shutdown could complete
|
||||
/// while the task was still touching disposed state. v2 regression net: alarm
|
||||
/// acknowledgement is sequential and awaited — verified by the integration test
|
||||
/// <c>AcknowledgeAsync</c> returning a completed Task that doesn't leave background
|
||||
/// work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_completes_before_returning_no_background_tasks()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// We can't easily acknowledge a real Galaxy alarm in this fixture, but we can
|
||||
// assert the call shape: a synchronous-from-the-caller-perspective await without
|
||||
// throwing or leaving a pending continuation.
|
||||
await _fx.Driver!.AcknowledgeAsync(
|
||||
new[] { new AlarmAcknowledgeRequest("nonexistent-source", "nonexistent-event", "test ack") },
|
||||
CancellationToken.None);
|
||||
|
||||
// If we got here, the call awaited cleanly — no fire-and-forget background work
|
||||
// left running after the caller returned.
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<!--
|
||||
We DO NOT reference Galaxy.Host (net48 x86) here. The Host runs as a subprocess —
|
||||
this project only needs to spawn the EXE and talk to it via named pipes through
|
||||
the Proxy. Cross-FX type loading is what bit the earlier in-process attempt.
|
||||
-->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,84 +0,0 @@
|
||||
using System;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmDiscoveryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 9 — IsAlarm must survive the MessagePack round-trip at Key=6 position.
|
||||
/// Regression guard: any reorder of keys in GalaxyAttributeInfo would silently corrupt
|
||||
/// the flag in the wire payload since MessagePack encodes by key number, not field name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GalaxyAttributeInfo_IsAlarm_round_trips_true_through_MessagePack()
|
||||
{
|
||||
var input = new GalaxyAttributeInfo
|
||||
{
|
||||
AttributeName = "TankLevel",
|
||||
MxDataType = 2,
|
||||
IsArray = false,
|
||||
ArrayDim = null,
|
||||
SecurityClassification = 1,
|
||||
IsHistorized = true,
|
||||
IsAlarm = true,
|
||||
};
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(input);
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
|
||||
decoded.IsAlarm.ShouldBeTrue();
|
||||
decoded.IsHistorized.ShouldBeTrue();
|
||||
decoded.AttributeName.ShouldBe("TankLevel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyAttributeInfo_IsAlarm_round_trips_false_through_MessagePack()
|
||||
{
|
||||
var input = new GalaxyAttributeInfo { AttributeName = "ColorRgb", IsAlarm = false };
|
||||
var bytes = MessagePackSerializer.Serialize(input);
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
decoded.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-compat guard: payloads serialized before PR 9 (which omit Key=6) must still
|
||||
/// deserialize cleanly — MessagePack treats missing keys as default. This lets a newer
|
||||
/// Proxy talk to an older Host during a rolling upgrade without a crash.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pre_PR9_payload_without_IsAlarm_key_deserializes_with_default_false()
|
||||
{
|
||||
// Build a 6-field payload (keys 0..5) matching the pre-PR9 shape by serializing a
|
||||
// stand-in class with the same key layout but no Key=6.
|
||||
var pre = new PrePR9Shape
|
||||
{
|
||||
AttributeName = "Legacy",
|
||||
MxDataType = 1,
|
||||
IsArray = false,
|
||||
ArrayDim = null,
|
||||
SecurityClassification = 0,
|
||||
IsHistorized = false,
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(pre);
|
||||
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
decoded.AttributeName.ShouldBe("Legacy");
|
||||
decoded.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PrePR9Shape
|
||||
{
|
||||
[Key(0)] public string AttributeName { get; set; } = string.Empty;
|
||||
[Key(1)] public int MxDataType { get; set; }
|
||||
[Key(2)] public bool IsArray { get; set; }
|
||||
[Key(3)] public uint? ArrayDim { get; set; }
|
||||
[Key(4)] public int SecurityClassification { get; set; }
|
||||
[Key(5)] public bool IsHistorized { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises <see cref="AvevaPrerequisites"/> against the live dev box so the helper
|
||||
/// itself gets integration coverage — i.e. "do the probes return Pass for things that
|
||||
/// really are Pass?" as validated against this machine's known-installed topology.
|
||||
/// Category <c>LiveGalaxy</c> so CI / clean dev boxes skip cleanly.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class AvevaPrerequisitesLiveTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public AvevaPrerequisitesLiveTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_Framework_install()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
_output.WriteLine(report.ToString());
|
||||
report.Checks.ShouldContain(c =>
|
||||
c.Name == "registry:ArchestrA.Framework" && c.Status == PrerequisiteStatus.Pass,
|
||||
"ArchestrA Framework registry root should be found on this machine.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaBootstrap_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var bootstrap = report.Checks.FirstOrDefault(c => c.Name == "service:aaBootstrap");
|
||||
bootstrap.ShouldNotBeNull();
|
||||
bootstrap.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaBootstrap must be Running for any live-Galaxy test to work — detail: {bootstrap.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_aaGR_running()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var gr = report.Checks.FirstOrDefault(c => c.Name == "service:aaGR");
|
||||
gr.ShouldNotBeNull();
|
||||
gr.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"aaGR (Galaxy Repository) must be Running — detail: {gr.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAll_on_live_box_reports_MxAccess_COM_registered()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
var com = report.Checks.FirstOrDefault(c => c.Name == "com:LMXProxy");
|
||||
com.ShouldNotBeNull();
|
||||
com.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"LMXProxy.LMXProxyServer ProgID must resolve to an InprocServer32 DLL — detail: {com.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_ZB_reachable()
|
||||
{
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(ct: CancellationToken.None);
|
||||
var zb = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB");
|
||||
zb.ShouldNotBeNull();
|
||||
zb.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"ZB database must be reachable via SQL Server Windows auth — detail: {zb.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckRepositoryOnly_on_live_box_reports_non_zero_deployed_objects()
|
||||
{
|
||||
// This box has 49 deployed objects per the research; we just assert > 0 so adding/
|
||||
// removing objects doesn't break the test.
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync();
|
||||
var deployed = report.Checks.FirstOrDefault(c => c.Name == "sql:ZB.deployedObjects");
|
||||
deployed.ShouldNotBeNull();
|
||||
deployed.Status.ShouldBe(PrerequisiteStatus.Pass,
|
||||
$"At least one deployed gobject should exist — detail: {deployed.Detail}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aveva_side_is_ready_on_this_machine()
|
||||
{
|
||||
// Narrower than "livetest ready" — our own services (OtOpcUa / OtOpcUaGalaxyHost)
|
||||
// may not be installed on a developer's box while they're actively iterating on
|
||||
// them, but the AVEVA side (Framework / Galaxy Repository / MXAccess COM /
|
||||
// SQL / core services) should always be up on a machine with System Platform
|
||||
// installed. This assertion is what gates live-Galaxy tests that go straight to
|
||||
// the Galaxy Repository without routing through our stack.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = false });
|
||||
_output.WriteLine(report.ToString());
|
||||
_output.WriteLine(report.Warnings ?? "no warnings");
|
||||
|
||||
// Enumerate AVEVA-side failures (if any) for an actionable assertion message.
|
||||
var avevaFails = report.Checks
|
||||
.Where(c => c.Status == PrerequisiteStatus.Fail &&
|
||||
c.Category != PrerequisiteCategory.OtOpcUaService)
|
||||
.ToList();
|
||||
report.IsAvevaSideReady.ShouldBeTrue(
|
||||
avevaFails.Count == 0
|
||||
? "unexpected state"
|
||||
: "AVEVA-side failures: " + string.Join(" ; ",
|
||||
avevaFails.Select(f => $"{f.Name}: {f.Detail}")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Report_captures_OtOpcUa_services_state_even_when_not_installed()
|
||||
{
|
||||
// The helper reports the status of OtOpcUaGalaxyHost + OtOpcUa services even if
|
||||
// they're not installed yet — absence is itself an actionable signal. This test
|
||||
// doesn't assert Pass/Fail on those services (their state depends on what's
|
||||
// installed when the test runs) — it only asserts the helper EMITTED the rows,
|
||||
// so nobody can ship a prerequisite check that silently omits our own services.
|
||||
var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUaGalaxyHost");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:OtOpcUa");
|
||||
report.Checks.ShouldContain(c => c.Name == "service:GLAuth");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives every <see cref="MessageKind"/> the Phase 2 plan exposes through the full
|
||||
/// Host-side stack (<see cref="PipeServer"/> + <see cref="GalaxyFrameHandler"/> +
|
||||
/// <see cref="StubGalaxyBackend"/>) using a hand-rolled IPC client built on Shared's
|
||||
/// <see cref="FrameReader"/>/<see cref="FrameWriter"/>. The Proxy's <c>GalaxyIpcClient</c>
|
||||
/// is net10-only and cannot load in this net48 x86 test process, so we exercise the same
|
||||
/// wire protocol through the framing primitives directly. The dispatcher/backend response
|
||||
/// shapes are the production code path verbatim.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class EndToEndIpcTests
|
||||
{
|
||||
private sealed class TestStack : IDisposable
|
||||
{
|
||||
public PipeServer Server = null!;
|
||||
public NamedPipeClientStream Stream = null!;
|
||||
public FrameReader Reader = null!;
|
||||
public FrameWriter Writer = null!;
|
||||
public Task ServerTask = null!;
|
||||
public CancellationTokenSource Cts = null!;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cts.Cancel();
|
||||
try { ServerTask.GetAwaiter().GetResult(); } catch { /* shutdown */ }
|
||||
Server.Dispose();
|
||||
Stream.Dispose();
|
||||
Reader.Dispose();
|
||||
Writer.Dispose();
|
||||
Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TestStack> StartAsync()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaGalaxyE2E-{Guid.NewGuid():N}";
|
||||
const string secret = "e2e-secret";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
|
||||
var server = new PipeServer(pipe, sid, secret, log);
|
||||
var serverTask = Task.Run(() => server.RunAsync(
|
||||
new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token));
|
||||
|
||||
var stream = new NamedPipeClientStream(".", pipe, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await stream.ConnectAsync(5_000, cts.Token);
|
||||
var reader = new FrameReader(stream, leaveOpen: true);
|
||||
var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
await writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { PeerName = "e2e", SharedSecret = secret }, cts.Token);
|
||||
var ack = await reader.ReadFrameAsync(cts.Token);
|
||||
if (ack is null || ack.Value.Kind != MessageKind.HelloAck)
|
||||
throw new InvalidOperationException("Hello handshake failed");
|
||||
|
||||
return new TestStack
|
||||
{
|
||||
Server = server,
|
||||
Stream = stream,
|
||||
Reader = reader,
|
||||
Writer = writer,
|
||||
ServerTask = serverTask,
|
||||
Cts = cts,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<TResp> RoundTripAsync<TReq, TResp>(
|
||||
TestStack s, MessageKind reqKind, TReq req, MessageKind respKind)
|
||||
{
|
||||
await s.Writer.WriteAsync(reqKind, req, s.Cts.Token);
|
||||
var frame = await s.Reader.ReadFrameAsync(s.Cts.Token);
|
||||
frame.HasValue.ShouldBeTrue();
|
||||
frame!.Value.Kind.ShouldBe(respKind);
|
||||
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenSession_succeeds_with_an_assigned_session_id()
|
||||
{
|
||||
using var s = await StartAsync();
|
||||
|
||||
var resp = await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
s, MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "gal-e2e", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.SessionId.ShouldBeGreaterThan(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_against_stub_returns_an_error_response()
|
||||
{
|
||||
using var s = await StartAsync();
|
||||
|
||||
var resp = await RoundTripAsync<DiscoverHierarchyRequest, DiscoverHierarchyResponse>(
|
||||
s, MessageKind.DiscoverHierarchyRequest,
|
||||
new DiscoverHierarchyRequest { SessionId = 1 },
|
||||
MessageKind.DiscoverHierarchyResponse);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("MXAccess code lift pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteValues_returns_per_tag_BadInternalError_status()
|
||||
{
|
||||
using var s = await StartAsync();
|
||||
|
||||
var resp = await RoundTripAsync<WriteValuesRequest, WriteValuesResponse>(
|
||||
s, MessageKind.WriteValuesRequest,
|
||||
new WriteValuesRequest
|
||||
{
|
||||
SessionId = 1,
|
||||
Writes = new[] { new GalaxyDataValue { TagReference = "TagA" } },
|
||||
},
|
||||
MessageKind.WriteValuesResponse);
|
||||
|
||||
resp.Results.Length.ShouldBe(1);
|
||||
resp.Results[0].StatusCode.ShouldBe(0x80020000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_returns_a_subscription_id()
|
||||
{
|
||||
using var s = await StartAsync();
|
||||
|
||||
var sub = await RoundTripAsync<SubscribeRequest, SubscribeResponse>(
|
||||
s, MessageKind.SubscribeRequest,
|
||||
new SubscribeRequest { SessionId = 1, TagReferences = new[] { "TagA" }, RequestedIntervalMs = 500 },
|
||||
MessageKind.SubscribeResponse);
|
||||
|
||||
sub.Success.ShouldBeTrue();
|
||||
sub.SubscriptionId.ShouldBeGreaterThan(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_returns_the_grace_window_from_the_backend()
|
||||
{
|
||||
using var s = await StartAsync();
|
||||
|
||||
var resp = await RoundTripAsync<RecycleHostRequest, RecycleStatusResponse>(
|
||||
s, MessageKind.RecycleHostRequest,
|
||||
new RecycleHostRequest { Kind = "Soft", Reason = "test" },
|
||||
MessageKind.RecycleStatusResponse);
|
||||
|
||||
resp.Accepted.ShouldBeTrue();
|
||||
resp.GraceSeconds.ShouldBe(15);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyAlarmTrackerTests
|
||||
{
|
||||
private sealed class FakeSubscriber
|
||||
{
|
||||
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||
public readonly ConcurrentQueue<string> Unsubs = new();
|
||||
public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new();
|
||||
public bool WriteReturns { get; set; } = true;
|
||||
|
||||
public Task Subscribe(string tag, Action<string, Vtq> cb)
|
||||
{
|
||||
Subs[tag] = cb;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task Unsubscribe(string tag)
|
||||
{
|
||||
Unsubs.Enqueue(tag);
|
||||
Subs.TryRemove(tag, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<bool> Write(string tag, object value)
|
||||
{
|
||||
Writes.Enqueue((tag, value));
|
||||
return Task.FromResult(WriteReturns);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192);
|
||||
private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192);
|
||||
private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192);
|
||||
|
||||
[Fact]
|
||||
public async Task Track_subscribes_to_four_alarm_attributes()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
|
||||
await t.TrackAsync("Tank.Level.HiHi");
|
||||
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked");
|
||||
t.TrackedAlarmCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Track_is_idempotent_on_repeat_call()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
await t.TrackAsync("Alarm.A");
|
||||
|
||||
t.TrackedAlarmCount.ShouldBe(1);
|
||||
fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InAlarm_false_to_true_fires_Active_transition()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500));
|
||||
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi"));
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Active);
|
||||
tr.Priority.ShouldBe(500);
|
||||
tr.DescAttrName.ShouldBe("TankLevelHiHi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InAlarm_true_to_false_fires_Inactive_transition()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false));
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acked_transition_while_InAlarm_is_false_does_not_fire()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
// Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire.
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_writes_AckMsg_with_comment()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
await t.TrackAsync("Alarm.A");
|
||||
|
||||
var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator");
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
fake.Writes.Count.ShouldBe(1);
|
||||
fake.Writes.TryDequeue(out var w).ShouldBeTrue();
|
||||
w.Tag.ShouldBe("Alarm.A.AckMsg");
|
||||
w.Value.ShouldBe("acknowledged by operator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_reports_latest_fields()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900));
|
||||
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm"));
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
|
||||
|
||||
var snap = t.SnapshotStates();
|
||||
snap.Count.ShouldBe(1);
|
||||
snap[0].InAlarm.ShouldBeTrue();
|
||||
snap[0].Acked.ShouldBeTrue();
|
||||
snap[0].Priority.ShouldBe(900);
|
||||
snap[0].DescAttrName.ShouldBe("MyAlarm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreign_probe_callback_is_dropped()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
// No TrackAsync was called — this callback is foreign and should be silently ignored.
|
||||
t.OnProbeCallback("Unknown.InAlarm", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Live smoke against the Galaxy <c>ZB</c> repository. Skipped when ZB is unreachable so
|
||||
/// CI / dev boxes without an AVEVA install still pass. Exercises the ported
|
||||
/// <see cref="GalaxyRepository"/> + <see cref="DbBackedGalaxyBackend"/> against the same
|
||||
/// SQL the v1 Host uses, proving the lift is byte-for-byte equivalent at the
|
||||
/// <c>DiscoverHierarchyResponse</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Since PR 36, skip logic is delegated to <see cref="AvevaPrerequisites.CheckRepositoryOnlyAsync"/>
|
||||
/// so operators see exactly why a test skipped ("ZB db not found" vs "SQL Server
|
||||
/// unreachable") instead of a silent return.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
public sealed class GalaxyRepositoryLiveSmokeTests
|
||||
{
|
||||
private static GalaxyRepositoryOptions DevZbOptions() => new()
|
||||
{
|
||||
ConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;",
|
||||
CommandTimeoutSeconds = 10,
|
||||
};
|
||||
|
||||
private static async Task<string?> RepositorySkipReasonAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
|
||||
var report = await AvevaPrerequisites.CheckRepositoryOnlyAsync(
|
||||
DevZbOptions().ConnectionString, cts.Token);
|
||||
return report.SkipReason;
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
// Legacy silent-skip adapter — keeps the existing tests compiling while
|
||||
// gradually migrating to the Skip-with-reason pattern. Returns true when the
|
||||
// prerequisite check has no Fail entries.
|
||||
return (await RepositorySkipReasonAsync()) is null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnection_returns_true_against_live_ZB()
|
||||
{
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
(await repo.TestConnectionAsync()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHierarchy_returns_at_least_one_deployed_gobject()
|
||||
{
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
var rows = await repo.GetHierarchyAsync();
|
||||
|
||||
rows.Count.ShouldBeGreaterThan(0,
|
||||
"the dev Galaxy has at least the WinPlatform + AppEngine deployed");
|
||||
rows.ShouldAllBe(r => !string.IsNullOrEmpty(r.TagName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttributes_returns_attributes_for_deployed_objects()
|
||||
{
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
var attrs = await repo.GetAttributesAsync();
|
||||
|
||||
attrs.Count.ShouldBeGreaterThan(0);
|
||||
attrs.ShouldAllBe(a => !string.IsNullOrEmpty(a.FullTagReference) && a.FullTagReference.Contains("."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLastDeployTime_returns_a_value()
|
||||
{
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var repo = new GalaxyRepository(DevZbOptions());
|
||||
var ts = await repo.GetLastDeployTimeAsync();
|
||||
ts.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbBackedBackend_DiscoverAsync_returns_objects_with_attributes_and_categories()
|
||||
{
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var backend = new DbBackedGalaxyBackend(new GalaxyRepository(DevZbOptions()));
|
||||
var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = 1 }, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue(resp.Error);
|
||||
resp.Objects.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
var firstWithAttrs = System.Linq.Enumerable.FirstOrDefault(resp.Objects, o => o.Attributes.Length > 0);
|
||||
firstWithAttrs.ShouldNotBeNull("at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||
firstWithAttrs!.TemplateCategory.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyRuntimeProbeManagerTests
|
||||
{
|
||||
private sealed class FakeSubscriber
|
||||
{
|
||||
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||
public readonly ConcurrentQueue<string> UnsubCalls = new();
|
||||
public bool FailSubscribeFor { get; set; }
|
||||
public string? FailSubscribeTag { get; set; }
|
||||
|
||||
public Task Subscribe(string probe, Action<string, Vtq> cb)
|
||||
{
|
||||
if (FailSubscribeFor && string.Equals(probe, FailSubscribeTag, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("subscribe refused");
|
||||
Subs[probe] = cb;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Unsubscribe(string probe)
|
||||
{
|
||||
UnsubCalls.Enqueue(probe);
|
||||
Subs.TryRemove(probe, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static Vtq Good(bool scanState) => new(scanState, DateTime.UtcNow, 192);
|
||||
private static Vtq Bad() => new(null, DateTime.UtcNow, 0);
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_subscribes_to_ScanState_per_host()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", GalaxyRuntimeProbeManager.CategoryWinPlatform),
|
||||
new HostProbeTarget("EngineB", GalaxyRuntimeProbeManager.CategoryAppEngine),
|
||||
});
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(2);
|
||||
subs.Subs.ShouldContainKey("PlatformA.ScanState");
|
||||
subs.Subs.ShouldContainKey("EngineB.ScanState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_is_idempotent_on_repeat_call_with_same_set()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
var targets = new[] { new HostProbeTarget("PlatformA", 1) };
|
||||
|
||||
await mgr.SyncAsync(targets);
|
||||
await mgr.SyncAsync(targets);
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(1);
|
||||
subs.Subs.Count.ShouldBe(1);
|
||||
subs.UnsubCalls.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_unadvises_removed_hosts()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", 1),
|
||||
new HostProbeTarget("PlatformB", 1),
|
||||
});
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(1);
|
||||
subs.UnsubCalls.ShouldContain("PlatformB.ScanState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_failure_rolls_back_host_entry_so_later_transitions_do_not_fire_stale_events()
|
||||
{
|
||||
var subs = new FakeSubscriber { FailSubscribeFor = true, FailSubscribeTag = "PlatformA.ScanState" };
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(0); // rolled back
|
||||
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_to_Running_does_not_fire_StateChanged()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||
|
||||
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Running);
|
||||
transitions.Count.ShouldBe(0); // startup transition, operators don't care
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Running_to_Stopped_fires_StateChanged_with_both_states()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.TagName.ShouldBe("PlatformA");
|
||||
t.OldState.ShouldBe(HostRuntimeState.Running);
|
||||
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stopped_to_Running_fires_StateChanged_for_recovery()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Stopped→Running (fires)
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_to_Stopped_fires_StateChanged_for_first_known_bad_signal()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
// First callback is bad-quality — we must flag the host Stopped so operators see it.
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Bad());
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.OldState.ShouldBe(HostRuntimeState.Unknown);
|
||||
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_Good_Running_callbacks_do_not_fire_duplicate_events()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var count = 0;
|
||||
mgr.StateChanged += (_, _) => Interlocked.Increment(ref count);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
for (var i = 0; i < 5; i++)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||
|
||||
count.ShouldBe(0); // only the silent Unknown→Running on the first, no events after
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_callback_for_non_tracked_probe_is_dropped()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
mgr.OnProbeCallback("ProbeForSomeoneElse.ScanState", Good(true));
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_reports_current_state_for_every_tracked_host()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", 1),
|
||||
new HostProbeTarget("EngineB", 3),
|
||||
});
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Running
|
||||
subs.Subs["EngineB.ScanState"]("EngineB.ScanState", Bad()); // Stopped
|
||||
|
||||
var snap = mgr.SnapshotStates();
|
||||
snap.Count.ShouldBe(2);
|
||||
snap.ShouldContain(s => s.TagName == "PlatformA" && s.State == HostRuntimeState.Running);
|
||||
snap.ShouldContain(s => s.TagName == "EngineB" && s.State == HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRuntimeHost_recognizes_WinPlatform_and_AppEngine_category_ids()
|
||||
{
|
||||
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryWinPlatform).IsRuntimeHost.ShouldBeTrue();
|
||||
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryAppEngine).IsRuntimeHost.ShouldBeTrue();
|
||||
new HostProbeTarget("X", 4 /* $Area */).IsRuntimeHost.ShouldBeFalse();
|
||||
new HostProbeTarget("X", 11 /* $ApplicationObject */).IsRuntimeHost.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianWiringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// When the Proxy sends a HistoryRead but the supervisor never enabled the historian
|
||||
/// (OTOPCUA_HISTORIAN_ENABLED unset), we expect a clean Success=false with a
|
||||
/// self-explanatory error — not an exception or a hang against localhost.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadAsync_returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests");
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var resp = await backend.HistoryReadAsync(new HistoryReadRequest
|
||||
{
|
||||
TagReferences = new[] { "TestTag" },
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxValuesPerTag = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
resp.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the historian is wired up, we expect the backend to call through and map
|
||||
/// samples onto the IPC wire shape. Uses a fake <see cref="IHistorianDataSource"/>
|
||||
/// that returns a single known-good sample so we can assert the mapping stays sane.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadAsync_maps_sample_to_GalaxyDataValue()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests");
|
||||
var fake = new FakeHistorianDataSource(new HistorianSample
|
||||
{
|
||||
Value = 42.5,
|
||||
Quality = 192, // Good
|
||||
TimestampUtc = new DateTime(2026, 4, 18, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadAsync(new HistoryReadRequest
|
||||
{
|
||||
TagReferences = new[] { "TankLevel" },
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxValuesPerTag = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Tags.Length.ShouldBe(1);
|
||||
resp.Tags[0].TagReference.ShouldBe("TankLevel");
|
||||
resp.Tags[0].Values.Length.ShouldBe(1);
|
||||
resp.Tags[0].Values[0].StatusCode.ShouldBe(0u); // Good
|
||||
resp.Tags[0].Values[0].ValueBytes.ShouldNotBeNull();
|
||||
resp.Tags[0].Values[0].SourceTimestampUtcUnixMs.ShouldBe(
|
||||
new DateTimeOffset(2026, 4, 18, 9, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianSample _sample;
|
||||
public FakeHistorianDataSource(HistorianSample sample) => _sample = sample;
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tagName, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample> { _sample });
|
||||
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tagName, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tagName, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadAtTimeTests
|
||||
{
|
||||
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) =>
|
||||
new(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"),
|
||||
historian);
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var backend = BuildBackend(null, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = new[] { 1L, 2L },
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var fake = new FakeHistorian();
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = Array.Empty<long>(),
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.ShouldBeEmpty();
|
||||
fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc);
|
||||
var fake = new FakeHistorian(
|
||||
new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 },
|
||||
new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 });
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "TankLevel",
|
||||
TimestampsUtcUnixMs = new[]
|
||||
{
|
||||
new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
},
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(2);
|
||||
resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192)
|
||||
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(100.0);
|
||||
|
||||
fake.Calls.ShouldBe(1);
|
||||
fake.LastTimestamps.Length.ShouldBe(2);
|
||||
fake.LastTimestamps[0].ShouldBe(t1);
|
||||
fake.LastTimestamps[1].ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_sample_maps_to_Bad_category()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
// Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync.
|
||||
var fake = new FakeHistorian(new HistorianSample
|
||||
{
|
||||
Value = null,
|
||||
Quality = 0,
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
});
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = new[] { 1L },
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category
|
||||
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianSample[] _samples;
|
||||
public int Calls { get; private set; }
|
||||
public DateTime[] LastTimestamps { get; private set; } = Array.Empty<DateTime>();
|
||||
|
||||
public FakeHistorian(params HistorianSample[] samples) => _samples = samples;
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
LastTimestamps = ts;
|
||||
return Task.FromResult(new List<HistorianSample>(_samples));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadEventsTests
|
||||
{
|
||||
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? h, StaPump pump) =>
|
||||
new(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
new MxAccessClient(pump, new MxProxyAdapter(), "events-test"),
|
||||
h);
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var backend = BuildBackend(null, pump);
|
||||
|
||||
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = "TankA",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxEvents = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_HistorianEventDto_to_GalaxyHistoricalEvent_wire_shape()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
var eventTime = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var receivedTime = eventTime.AddMilliseconds(150);
|
||||
var fake = new FakeHistorian(new HistorianEventDto
|
||||
{
|
||||
Id = eventId,
|
||||
Source = "TankA.Level.HiHi",
|
||||
EventTime = eventTime,
|
||||
ReceivedTime = receivedTime,
|
||||
DisplayText = "HiHi alarm tripped",
|
||||
Severity = 900,
|
||||
});
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = "TankA.Level.HiHi",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxEvents = 50,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Events.Length.ShouldBe(1);
|
||||
var got = resp.Events[0];
|
||||
got.EventId.ShouldBe(eventId.ToString());
|
||||
got.SourceName.ShouldBe("TankA.Level.HiHi");
|
||||
got.DisplayText.ShouldBe("HiHi alarm tripped");
|
||||
got.Severity.ShouldBe<ushort>(900);
|
||||
got.EventTimeUtcUnixMs.ShouldBe(new DateTimeOffset(eventTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
got.ReceivedTimeUtcUnixMs.ShouldBe(new DateTimeOffset(receivedTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
|
||||
fake.LastSourceName.ShouldBe("TankA.Level.HiHi");
|
||||
fake.LastMaxEvents.ShouldBe(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_source_name_is_passed_through_as_all_sources()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var fake = new FakeHistorian();
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = null,
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = 1,
|
||||
MaxEvents = 10,
|
||||
}, CancellationToken.None);
|
||||
|
||||
fake.LastSourceName.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianEventDto[] _events;
|
||||
public string? LastSourceName { get; private set; } = "<unset>";
|
||||
public int LastMaxEvents { get; private set; }
|
||||
|
||||
public FakeHistorian(params HistorianEventDto[] events) => _events = events;
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
{
|
||||
LastSourceName = src;
|
||||
LastMaxEvents = max;
|
||||
return Task.FromResult(new List<HistorianEventDto>(_events));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadProcessedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnsDisabledError_When_NoHistorianConfigured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
IntervalMs = 1000,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_NonPositiveInterval()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource();
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
IntervalMs = 0,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("IntervalMs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_AggregateSample_With_Value_To_Good()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||
{
|
||||
Value = 12.34,
|
||||
TimestampUtc = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
IntervalMs = 60_000,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0u); // Good
|
||||
resp.Values[0].ValueBytes.ShouldNotBeNull();
|
||||
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(12.34);
|
||||
fake.LastAggregateColumn.ShouldBe("Average");
|
||||
fake.LastIntervalMs.ShouldBe(60_000d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_Null_Bucket_To_BadNoData()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||
{
|
||||
Value = null,
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
IntervalMs = 1000,
|
||||
AggregateColumn = "Minimum",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0x800E0000u); // BadNoData
|
||||
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianAggregateSample[] _samples;
|
||||
public string? LastAggregateColumn { get; private set; }
|
||||
public double LastIntervalMs { get; private set; }
|
||||
|
||||
public FakeHistorianDataSource(params HistorianAggregateSample[] samples) => _samples = samples;
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tag, DateTime s, DateTime e, double intervalMs, string col, CancellationToken ct)
|
||||
{
|
||||
LastAggregateColumn = col;
|
||||
LastIntervalMs = intervalMs;
|
||||
return Task.FromResult(new List<HistorianAggregateSample>(_samples));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HostStatusPushTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false,
|
||||
/// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with
|
||||
/// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp.
|
||||
/// This is the gateway-level signal; per-platform ScanState probes are deferred.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var proxy = new FakeProxy();
|
||||
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var notifications = new ConcurrentQueue<HostConnectivityStatus>();
|
||||
backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s);
|
||||
|
||||
await mx.ConnectAsync();
|
||||
await mx.DisconnectAsync();
|
||||
|
||||
notifications.Count.ShouldBe(2);
|
||||
notifications.TryDequeue(out var first).ShouldBeTrue();
|
||||
first!.HostName.ShouldBe("GatewayClient");
|
||||
first.RuntimeStatus.ShouldBe("Running");
|
||||
first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0);
|
||||
|
||||
notifications.TryDequeue(out var second).ShouldBeTrue();
|
||||
second!.HostName.ShouldBe("GatewayClient");
|
||||
second.RuntimeStatus.ShouldBe("Stopped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var proxy = new FakeProxy();
|
||||
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||
var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var count = 0;
|
||||
backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count);
|
||||
|
||||
await mx.ConnectAsync();
|
||||
count.ShouldBe(1);
|
||||
|
||||
backend.Dispose();
|
||||
await mx.DisconnectAsync();
|
||||
|
||||
count.ShouldBe(1); // no second notification after Dispose
|
||||
}
|
||||
|
||||
private sealed class FakeProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
public int AddItem(int _, string __) => Interlocked.Increment(ref _next);
|
||||
public void RemoveItem(int _, int __) { }
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Direct IPC handshake test — drives <see cref="PipeServer"/> with a hand-rolled client
|
||||
/// built on <see cref="FrameReader"/>/<see cref="FrameWriter"/> from Shared. Stays in
|
||||
/// net48 x86 alongside the Host (the Proxy's <c>GalaxyIpcClient</c> is net10 only and
|
||||
/// cannot be loaded into this process). Functionally equivalent to going through
|
||||
/// <c>GalaxyIpcClient</c> — proves the wire protocol + ACL + shared-secret enforcement.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class IpcHandshakeIntegrationTests
|
||||
{
|
||||
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
|
||||
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await stream.ConnectAsync(5_000, ct);
|
||||
|
||||
var reader = new FrameReader(stream, leaveOpen: true);
|
||||
var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
await writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
|
||||
|
||||
var ack = await reader.ReadFrameAsync(ct);
|
||||
if (ack is null) throw new EndOfStreamException("no HelloAck");
|
||||
if (ack.Value.Kind != MessageKind.HelloAck) throw new InvalidOperationException("unexpected first frame");
|
||||
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
|
||||
|
||||
return (stream, reader, writer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
|
||||
const string secret = "test-secret-2026";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var server = new PipeServer(pipe, sid, secret, log);
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(
|
||||
new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token));
|
||||
|
||||
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
|
||||
using (stream)
|
||||
using (reader)
|
||||
using (writer)
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.Heartbeat,
|
||||
new Heartbeat { SequenceNumber = 42, UtcUnixMs = 1000 }, cts.Token);
|
||||
|
||||
var hbAckFrame = await reader.ReadFrameAsync(cts.Token);
|
||||
hbAckFrame.HasValue.ShouldBeTrue();
|
||||
hbAckFrame!.Value.Kind.ShouldBe(MessageKind.HeartbeatAck);
|
||||
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAckFrame.Value.Body).SequenceNumber.ShouldBe(42L);
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
try { await serverTask; } catch { /* shutdown */ }
|
||||
server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handshake_with_wrong_secret_is_rejected()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipe = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}";
|
||||
Logger log = new LoggerConfiguration().CreateLogger();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
|
||||
var server = new PipeServer(pipe, sid, "real-secret", log);
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(
|
||||
new GalaxyFrameHandler(new StubGalaxyBackend(), log), cts.Token));
|
||||
|
||||
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
|
||||
{
|
||||
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
|
||||
s.Dispose();
|
||||
r.Dispose();
|
||||
w.Dispose();
|
||||
});
|
||||
|
||||
cts.Cancel();
|
||||
try { await serverTask; } catch { /* shutdown */ }
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MemoryWatchdogTests
|
||||
{
|
||||
private const long Mb = 1024 * 1024;
|
||||
|
||||
[Fact]
|
||||
public void Baseline_sample_returns_None()
|
||||
{
|
||||
var w = new MemoryWatchdog(baselineBytes: 300 * Mb);
|
||||
w.Sample(320 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warn_threshold_uses_larger_of_1_5x_or_plus_200MB()
|
||||
{
|
||||
// Baseline 300 → warn threshold = max(450, 500) = 500 MB
|
||||
var w = new MemoryWatchdog(baselineBytes: 300 * Mb);
|
||||
w.Sample(499 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None);
|
||||
w.Sample(500 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Soft_recycle_triggers_at_2x_or_plus_200MB_whichever_larger()
|
||||
{
|
||||
// Baseline 400 → soft = max(800, 600) = 800 MB
|
||||
var w = new MemoryWatchdog(baselineBytes: 400 * Mb);
|
||||
w.Sample(799 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn);
|
||||
w.Sample(800 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.SoftRecycle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hard_kill_triggers_at_absolute_ceiling()
|
||||
{
|
||||
var w = new MemoryWatchdog(baselineBytes: 1000 * Mb);
|
||||
w.Sample(1501 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.HardKill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sustained_slope_triggers_soft_recycle_before_absolute_threshold()
|
||||
{
|
||||
// Baseline 1000 MB → warn = 1200, soft = 2000 (absolute). Slope 6 MB/min over 30 min = 180 MB
|
||||
// delta — still well below the absolute soft threshold; slope detector must fire on its own.
|
||||
var w = new MemoryWatchdog(baselineBytes: 1000 * Mb) { SustainedSlopeBytesPerMinute = 5 * Mb };
|
||||
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
long rss = 1050 * Mb;
|
||||
var slopeFired = false;
|
||||
for (var i = 0; i <= 35; i++)
|
||||
{
|
||||
var action = w.Sample(rss, t0.AddMinutes(i));
|
||||
if (action == WatchdogAction.SoftRecycle) { slopeFired = true; break; }
|
||||
rss += 6 * Mb;
|
||||
}
|
||||
|
||||
slopeFired.ShouldBeTrue("slope detector should fire once the 30-min window fills");
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MxAccessClientMonitorLoopTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 6 low finding #1 — every $Heartbeat probe must RemoveItem the item handle it
|
||||
/// allocated. Without that, the monitor leaks one handle per MonitorInterval seconds,
|
||||
/// which over a 24h uptime becomes thousands of leaked MXAccess handles and can
|
||||
/// eventually exhaust the runtime proxy's handle table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Heartbeat_probe_calls_RemoveItem_for_every_AddItem()
|
||||
{
|
||||
using var pump = new StaPump("Monitor.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var proxy = new CountingProxy();
|
||||
var client = new MxAccessClient(pump, proxy, "probe-test", new MxAccessClientOptions
|
||||
{
|
||||
AutoReconnect = true,
|
||||
MonitorInterval = TimeSpan.FromMilliseconds(150),
|
||||
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||
});
|
||||
|
||||
await client.ConnectAsync();
|
||||
|
||||
// Wait past StaleThreshold, then let several monitor cycles fire.
|
||||
await Task.Delay(700);
|
||||
|
||||
client.Dispose();
|
||||
|
||||
// One Heartbeat probe fires per monitor tick once the connection looks stale.
|
||||
proxy.HeartbeatAddCount.ShouldBeGreaterThan(1);
|
||||
// Every AddItem("$Heartbeat") must be matched by a RemoveItem on the same handle.
|
||||
proxy.HeartbeatAddCount.ShouldBe(proxy.HeartbeatRemoveCount);
|
||||
proxy.OutstandingHeartbeatHandles.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 6 low finding #2 — after reconnect, per-subscription replay failures must raise
|
||||
/// SubscriptionReplayFailed so the backend can propagate the degradation, not get
|
||||
/// silently eaten.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscriptionReplayFailed_fires_for_each_tag_that_fails_to_replay()
|
||||
{
|
||||
using var pump = new StaPump("Replay.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var proxy = new ReplayFailingProxy(failOnReplayForTags: new[] { "BadTag.A", "BadTag.B" });
|
||||
var client = new MxAccessClient(pump, proxy, "replay-test", new MxAccessClientOptions
|
||||
{
|
||||
AutoReconnect = true,
|
||||
MonitorInterval = TimeSpan.FromMilliseconds(120),
|
||||
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||
});
|
||||
|
||||
var failures = new ConcurrentBag<SubscriptionReplayFailedEventArgs>();
|
||||
client.SubscriptionReplayFailed += (_, e) => failures.Add(e);
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.SubscribeAsync("GoodTag.X", (_, _) => { });
|
||||
await client.SubscribeAsync("BadTag.A", (_, _) => { });
|
||||
await client.SubscribeAsync("BadTag.B", (_, _) => { });
|
||||
|
||||
proxy.TriggerProbeFailureOnNextCall();
|
||||
|
||||
// Wait for the monitor loop to probe → fail → reconnect → replay.
|
||||
await Task.Delay(800);
|
||||
|
||||
client.Dispose();
|
||||
|
||||
failures.Count.ShouldBe(2);
|
||||
var names = new HashSet<string>();
|
||||
foreach (var f in failures) names.Add(f.TagReference);
|
||||
names.ShouldContain("BadTag.A");
|
||||
names.ShouldContain("BadTag.B");
|
||||
}
|
||||
|
||||
// ----- test doubles -----
|
||||
|
||||
private sealed class CountingProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
private readonly ConcurrentDictionary<int, string> _live = new();
|
||||
|
||||
public int HeartbeatAddCount;
|
||||
public int HeartbeatRemoveCount;
|
||||
public int OutstandingHeartbeatHandles => _live.Count;
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
|
||||
public int AddItem(int _, string address)
|
||||
{
|
||||
var h = Interlocked.Increment(ref _next);
|
||||
_live[h] = address;
|
||||
if (address == "$Heartbeat") Interlocked.Increment(ref HeartbeatAddCount);
|
||||
return h;
|
||||
}
|
||||
|
||||
public void RemoveItem(int _, int itemHandle)
|
||||
{
|
||||
if (_live.TryRemove(itemHandle, out var addr) && addr == "$Heartbeat")
|
||||
Interlocked.Increment(ref HeartbeatRemoveCount);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock that lets us exercise the reconnect + replay path. TriggerProbeFailureOnNextCall
|
||||
/// flips a one-shot flag so the very next AddItem("$Heartbeat") throws — that drives the
|
||||
/// monitor loop into the reconnect-with-replay branch. During the replay, AddItem for the
|
||||
/// tags listed in failOnReplayForTags throws so SubscriptionReplayFailed should fire once
|
||||
/// per failing tag.
|
||||
/// </summary>
|
||||
private sealed class ReplayFailingProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
private readonly HashSet<string> _failOnReplay;
|
||||
private int _probeFailOnce;
|
||||
private readonly ConcurrentDictionary<string, bool> _replayedOnce = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ReplayFailingProxy(IEnumerable<string> failOnReplayForTags)
|
||||
{
|
||||
_failOnReplay = new HashSet<string>(failOnReplayForTags, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void TriggerProbeFailureOnNextCall() => Interlocked.Exchange(ref _probeFailOnce, 1);
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
|
||||
public int AddItem(int _, string address)
|
||||
{
|
||||
if (address == "$Heartbeat" && Interlocked.Exchange(ref _probeFailOnce, 0) == 1)
|
||||
throw new InvalidOperationException("simulated probe failure");
|
||||
|
||||
// Fail only on the *replay* AddItem for listed tags — not the initial subscribe.
|
||||
if (_failOnReplay.Contains(address) && _replayedOnce.ContainsKey(address))
|
||||
throw new InvalidOperationException($"simulated replay failure for {address}");
|
||||
|
||||
if (_failOnReplay.Contains(address)) _replayedOnce[address] = true;
|
||||
return Interlocked.Increment(ref _next);
|
||||
}
|
||||
|
||||
public void RemoveItem(int _, int __) { }
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the live MXAccess COM runtime + Galaxy ZB DB on this dev box.
|
||||
/// Skipped when ArchestrA bootstrap (<c>aaBootstrap</c>) isn't running. Verifies the
|
||||
/// ported <see cref="MxAccessClient"/> can connect to <c>LMXProxyServer</c>, the
|
||||
/// <see cref="MxAccessGalaxyBackend"/> can answer Discover against the live ZB schema,
|
||||
/// and a one-shot read returns a valid VTQ for the first deployed attribute it finds.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveMxAccess")]
|
||||
public sealed class MxAccessLiveSmokeTests
|
||||
{
|
||||
private static GalaxyRepositoryOptions DevZb() => new()
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=2;",
|
||||
CommandTimeoutSeconds = 10,
|
||||
};
|
||||
|
||||
private static async Task<bool> ArchestraReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var repo = new GalaxyRepository(DevZb());
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
if (!await repo.TestConnectionAsync(cts.Token)) return false;
|
||||
|
||||
using var sc = new System.ServiceProcess.ServiceController("aaBootstrap");
|
||||
return sc.Status == System.ServiceProcess.ServiceControllerStatus.Running;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_local_LMXProxyServer_succeeds()
|
||||
{
|
||||
if (!await ArchestraReachableAsync()) return;
|
||||
|
||||
using var pump = new StaPump("MxA-test-pump");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke");
|
||||
var handle = await mx.ConnectAsync();
|
||||
handle.ShouldBeGreaterThan(0);
|
||||
mx.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_OpenSession_then_Discover_returns_objects_with_attributes()
|
||||
{
|
||||
if (!await ArchestraReachableAsync()) return;
|
||||
|
||||
using var pump = new StaPump("MxA-test-pump");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke");
|
||||
var backend = new MxAccessGalaxyBackend(new GalaxyRepository(DevZb()), mx);
|
||||
|
||||
var session = await backend.OpenSessionAsync(new OpenSessionRequest { DriverInstanceId = "smoke" }, CancellationToken.None);
|
||||
session.Success.ShouldBeTrue(session.Error);
|
||||
|
||||
var resp = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = session.SessionId }, CancellationToken.None);
|
||||
resp.Success.ShouldBeTrue(resp.Error);
|
||||
resp.Objects.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live one-shot read against any attribute we discover. Best-effort — passes silently
|
||||
/// if no readable attribute is exposed (some Galaxy installs are configuration-only;
|
||||
/// we only assert the call shape is correct, not a specific value).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Backend_ReadValues_against_discovered_attribute_returns_a_response_shape()
|
||||
{
|
||||
if (!await ArchestraReachableAsync()) return;
|
||||
|
||||
using var pump = new StaPump("MxA-test-pump");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var mx = new MxAccessClient(pump, new MxProxyAdapter(), "OtOpcUa-MxAccessSmoke");
|
||||
var backend = new MxAccessGalaxyBackend(new GalaxyRepository(DevZb()), mx);
|
||||
|
||||
var session = await backend.OpenSessionAsync(new OpenSessionRequest { DriverInstanceId = "smoke" }, CancellationToken.None);
|
||||
var disc = await backend.DiscoverAsync(new DiscoverHierarchyRequest { SessionId = session.SessionId }, CancellationToken.None);
|
||||
var firstAttr = System.Linq.Enumerable.FirstOrDefault(disc.Objects, o => o.Attributes.Length > 0);
|
||||
if (firstAttr is null)
|
||||
{
|
||||
await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
|
||||
var fullRef = $"{firstAttr.TagName}.{firstAttr.Attributes[0].AttributeName}";
|
||||
var read = await backend.ReadValuesAsync(
|
||||
new ReadValuesRequest { SessionId = session.SessionId, TagReferences = new[] { fullRef } },
|
||||
CancellationToken.None);
|
||||
|
||||
read.Success.ShouldBeTrue();
|
||||
read.Values.Length.ShouldBe(1);
|
||||
// We don't assert the value (it may be Bad/Uncertain depending on what's running);
|
||||
// we only assert the response shape is correct end-to-end.
|
||||
read.Values[0].TagReference.ShouldBe(fullRef);
|
||||
|
||||
await backend.CloseSessionAsync(new CloseSessionRequest { SessionId = session.SessionId }, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PostMortemMmfTests : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(Path.GetTempPath(), $"mmf-test-{Guid.NewGuid():N}.bin");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_path)) File.Delete(_path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_then_read_round_trips_entries_in_oldest_first_order()
|
||||
{
|
||||
using (var mmf = new PostMortemMmf(_path, capacity: 10))
|
||||
{
|
||||
mmf.Write(0x30, "read tag-1");
|
||||
mmf.Write(0x30, "read tag-2");
|
||||
mmf.Write(0x32, "write tag-3");
|
||||
}
|
||||
|
||||
using var reopen = new PostMortemMmf(_path, capacity: 10);
|
||||
var entries = reopen.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
entries[0].Message.ShouldBe("read tag-1");
|
||||
entries[1].Message.ShouldBe("read tag-2");
|
||||
entries[2].Message.ShouldBe("write tag-3");
|
||||
entries[0].OpKind.ShouldBe(0x30L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ring_buffer_wraps_and_oldest_entry_is_overwritten()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_path, capacity: 3);
|
||||
mmf.Write(1, "A");
|
||||
mmf.Write(2, "B");
|
||||
mmf.Write(3, "C");
|
||||
mmf.Write(4, "D"); // overwrites A
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries.Length.ShouldBe(3);
|
||||
entries[0].Message.ShouldBe("B");
|
||||
entries[1].Message.ShouldBe("C");
|
||||
entries[2].Message.ShouldBe("D");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_longer_than_capacity_is_truncated_safely()
|
||||
{
|
||||
using var mmf = new PostMortemMmf(_path, capacity: 2);
|
||||
var huge = new string('x', 500);
|
||||
mmf.Write(0, huge);
|
||||
|
||||
var entries = mmf.ReadAll();
|
||||
entries[0].Message.Length.ShouldBeLessThan(PostMortemMmf.EntryBytes);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RecyclePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void First_soft_recycle_is_allowed()
|
||||
{
|
||||
var p = new RecyclePolicy();
|
||||
p.TryRequestSoftRecycle(DateTime.UtcNow, out var reason).ShouldBeTrue();
|
||||
reason.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_soft_recycle_within_cap_is_blocked()
|
||||
{
|
||||
var p = new RecyclePolicy();
|
||||
var t0 = DateTime.UtcNow;
|
||||
p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue();
|
||||
p.TryRequestSoftRecycle(t0.AddMinutes(30), out var reason).ShouldBeFalse();
|
||||
reason.ShouldContain("frequency cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recycle_after_cap_elapses_is_allowed_again()
|
||||
{
|
||||
var p = new RecyclePolicy();
|
||||
var t0 = DateTime.UtcNow;
|
||||
p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue();
|
||||
p.TryRequestSoftRecycle(t0.AddHours(1).AddMinutes(1), out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scheduled_recycle_fires_once_per_day_at_local_3am()
|
||||
{
|
||||
var p = new RecyclePolicy();
|
||||
var last = DateTime.MinValue;
|
||||
|
||||
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 2, 59, 0), ref last).ShouldBeFalse();
|
||||
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 0, 0), ref last).ShouldBeTrue();
|
||||
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 30, 0), ref last).ShouldBeFalse(
|
||||
"already fired today");
|
||||
p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 18, 3, 0, 0), ref last).ShouldBeTrue(
|
||||
"next day fires again");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StaPumpTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_runs_work_on_the_STA_thread()
|
||||
{
|
||||
using var pump = new StaPump();
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var apartment = await pump.InvokeAsync(() => Thread.CurrentThread.GetApartmentState());
|
||||
apartment.ShouldBe(ApartmentState.STA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Responsiveness_probe_returns_true_under_healthy_pump()
|
||||
{
|
||||
using var pump = new StaPump();
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
(await pump.IsResponsiveAsync(TimeSpan.FromSeconds(2))).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Responsiveness_probe_returns_false_when_pump_is_wedged()
|
||||
{
|
||||
using var pump = new StaPump();
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
// Wedge the pump with an infinite work item on the STA thread.
|
||||
var wedge = new ManualResetEventSlim();
|
||||
_ = pump.InvokeAsync(() => wedge.Wait());
|
||||
|
||||
var responsive = await pump.IsResponsiveAsync(TimeSpan.FromMilliseconds(500));
|
||||
responsive.ShouldBeFalse();
|
||||
|
||||
wedge.Set();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
|
||||
implementing the interface must resolve that type at compile time. -->
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,103 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.5 — Alarm-condition + transition parity. Both backends discover the
|
||||
/// same set of alarm-bearing attributes with matching <see cref="AlarmConditionInfo"/>
|
||||
/// metadata; transition events from a live alarm flap must arrive with matching
|
||||
/// severity, message, and source-node-id on each side.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Alarm-event persistence parity (the SQLite store-and-forward → Wonderware
|
||||
/// historian event store path called out in the impl plan) is exercised
|
||||
/// end-to-end in PR 5.6 against the historian sidecar; here we focus on the
|
||||
/// in-process transition stream that <see cref="IAlarmConditionSink"/> emits.
|
||||
/// </remarks>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class AlarmTransitionParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public AlarmTransitionParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_same_AlarmConditionInfo_per_alarm_attribute()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
return b.AlarmConditions.ToDictionary(
|
||||
ac => ac.SourceNodeId,
|
||||
ac => ac.Info,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
if (legacy.Count == 0)
|
||||
{
|
||||
Assert.Skip("dev Galaxy has no alarm-marked attributes — alarm parity unverified for this rig");
|
||||
}
|
||||
|
||||
legacy.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ShouldBe(mxgw.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase),
|
||||
"alarm source-node-id set must match across backends");
|
||||
|
||||
foreach (var kvp in legacy)
|
||||
{
|
||||
mxgw[kvp.Key].InitialSeverity.ShouldBe(kvp.Value.InitialSeverity,
|
||||
$"alarm severity parity for '{kvp.Key}'");
|
||||
mxgw[kvp.Key].SourceName.ShouldBe(kvp.Value.SourceName,
|
||||
$"alarm SourceName parity for '{kvp.Key}'");
|
||||
|
||||
// PR 2.1 added the five sub-attribute refs (InAlarmRef / PriorityRef /
|
||||
// DescAttrNameRef / AckedRef / AckMsgWriteRef) so the new server-side
|
||||
// AlarmConditionService can subscribe + ack-write without help from the
|
||||
// driver. The new mxgw GalaxyDriver populates them via AlarmRefBuilder
|
||||
// (PR 4.1). The legacy GalaxyProxyDriver pre-dates PR 2.1 and leaves them
|
||||
// null — that's an accepted delta until the legacy backend retires in
|
||||
// PR 7.2. Asserting "mxgw populated when legacy didn't" is *correct*
|
||||
// behavior, not a regression.
|
||||
//
|
||||
// We pin the weaker invariant: if legacy populated a ref, mxgw must
|
||||
// populate the same value. If legacy is null, mxgw is allowed to be
|
||||
// either null or populated (the population-from-AlarmRefBuilder direction).
|
||||
if (kvp.Value.InAlarmRef is not null)
|
||||
{
|
||||
mxgw[kvp.Key].InAlarmRef.ShouldBe(kvp.Value.InAlarmRef,
|
||||
$"alarm InAlarmRef parity for '{kvp.Key}' (both populated)");
|
||||
}
|
||||
if (kvp.Value.DescAttrNameRef is not null)
|
||||
{
|
||||
mxgw[kvp.Key].DescAttrNameRef.ShouldBe(kvp.Value.DescAttrNameRef,
|
||||
$"alarm DescAttrNameRef parity for '{kvp.Key}' (both populated)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_marks_at_least_one_alarm_attribute_when_dev_Galaxy_has_alarms()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
return b.Variables.Count(v => v.AttributeInfo.IsAlarm);
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Soft pin — count must match across backends. Whether the count is non-zero
|
||||
// depends on the rig's Galaxy content, so we don't gate on a positive number.
|
||||
snapshots[ParityHarness.Backend.LegacyHost]
|
||||
.ShouldBe(snapshots[ParityHarness.Backend.MxGateway],
|
||||
"IsAlarm-marked variable count parity");
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.2 — Browse + read parity. Discovers the address space through both
|
||||
/// backends and asserts the surface they expose matches: same folder set,
|
||||
/// same variable set, same DataType / SecurityClass / IsHistorized flags.
|
||||
/// Then reads a sample of resolved variables and diffs the snapshot triplets.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class BrowseAndReadParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public BrowseAndReadParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_same_variable_set_for_both_backends()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
return b;
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
var legacyRefs = legacy.Variables.Select(v => v.AttributeInfo.FullName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var mxgwRefs = mxgw.Variables.Select(v => v.AttributeInfo.FullName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Symmetric difference must be empty — the in-process driver and the legacy
|
||||
// proxy walk the same Galaxy ZB hierarchy, so their full-reference sets
|
||||
// must agree exactly.
|
||||
legacyRefs.Except(mxgwRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty();
|
||||
mxgwRefs.Except(legacyRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_same_DataType_and_SecurityClass_per_attribute()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
return b.Variables.ToDictionary(
|
||||
v => v.AttributeInfo.FullName,
|
||||
v => (v.AttributeInfo.DriverDataType, v.AttributeInfo.SecurityClass, v.AttributeInfo.IsHistorized),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
foreach (var kvp in legacy)
|
||||
{
|
||||
var fullRef = kvp.Key;
|
||||
mxgw.ShouldContainKey(fullRef);
|
||||
mxgw[fullRef].ShouldBe(kvp.Value,
|
||||
$"DataType/SecurityClass/IsHistorized must match for '{fullRef}'");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_returns_same_value_and_status_for_a_sampled_attribute()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
// Discover via the legacy backend, pick a sample, then read the same address
|
||||
// through both backends. We sample a small handful so the test stays fast and
|
||||
// doesn't hammer ZB / the gateway.
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
var sample = b.Variables.Take(5).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||
|
||||
var reads = await _h.RunOnAvailableAsync(
|
||||
(driver, ct) => ((IReadable)driver).ReadAsync(sample, ct),
|
||||
CancellationToken.None);
|
||||
|
||||
var legacyReads = reads[ParityHarness.Backend.LegacyHost];
|
||||
var mxgwReads = reads[ParityHarness.Backend.MxGateway];
|
||||
|
||||
legacyReads.Count.ShouldBe(sample.Length);
|
||||
mxgwReads.Count.ShouldBe(sample.Length);
|
||||
|
||||
for (var i = 0; i < sample.Length; i++)
|
||||
{
|
||||
// StatusCode must agree on the same status *class* (Good / Uncertain / Bad).
|
||||
// Per Galaxy.ParityMatrix.md "Accepted deltas", legacy and mxgw map
|
||||
// MxAccess HRESULTs to different exact OPC UA codes — pinning the class
|
||||
// is the parity invariant.
|
||||
(legacyReads[i].StatusCode & 0xC0000000u)
|
||||
.ShouldBe(mxgwReads[i].StatusCode & 0xC0000000u,
|
||||
$"StatusCode class parity for '{sample[i]}': legacy=0x{legacyReads[i].StatusCode:X8}, mxgw=0x{mxgwReads[i].StatusCode:X8}");
|
||||
|
||||
// Value-CLR-type parity is intentionally NOT asserted. Legacy returns the
|
||||
// raw VARIANT (e.g. byte[]) for an attribute that hasn't received its first
|
||||
// value cycle from MxAccess yet, while mxgw returns the typed value
|
||||
// (Float, Int32, etc.) — and both null-vs-typed combinations occur on a
|
||||
// live galaxy. The status-class assertion above pins the parity invariant
|
||||
// that *matters* (Bad-vs-Good). The encoding-specific CLR type isn't
|
||||
// load-bearing for the parity gate. Accepted delta — see
|
||||
// Galaxy.ParityMatrix.md.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shape tests for the <see cref="ParityHarness"/> itself — these run regardless of
|
||||
/// dev-environment availability. The scenario tests in PR 5.2–5.8 carry the actual
|
||||
/// parity assertions and are guarded by <see cref="ParityHarness.RequireBoth"/>.
|
||||
/// </summary>
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class HarnessShapeTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public HarnessShapeTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public void Harness_records_a_skip_reason_for_each_unavailable_backend()
|
||||
{
|
||||
// Either the backend resolved (driver != null, skipReason == null) or it didn't
|
||||
// (driver == null, skipReason populated). Asserting the invariant lets the parity
|
||||
// matrix doc (PR 5.W) faithfully report "n/a, reason: ..." for unreachable rigs.
|
||||
(_h.LegacyDriver is null).ShouldBe(_h.LegacySkipReason is not null);
|
||||
(_h.MxGatewayDriver is null).ShouldBe(_h.MxGatewaySkipReason is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunOnAvailableAsync_yields_one_entry_per_resolved_backend()
|
||||
{
|
||||
var calls = await _h.RunOnAvailableAsync(
|
||||
(_, _) => Task.FromResult(1), CancellationToken.None);
|
||||
|
||||
var expected = (_h.LegacyDriver is null ? 0 : 1) + (_h.MxGatewayDriver is null ? 0 : 1);
|
||||
calls.Count.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.6 — History-read parity. Phase-1 routing lifted history off the
|
||||
/// per-driver <see cref="IHistoryProvider"/> path onto the server-owned
|
||||
/// <c>HistoryRouter</c> + <c>WonderwareHistorianBootstrap</c>; neither
|
||||
/// Galaxy backend implements <see cref="IHistoryProvider"/> directly. So
|
||||
/// the parity surface here is the *routing decision*: both backends must
|
||||
/// identify the same set of historized attributes and produce the same
|
||||
/// full-reference for each, so HistoryRouter routes reads identically.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class HistoryReadParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public HistoryReadParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_same_historized_attribute_set_for_both_backends()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
return b.Variables
|
||||
.Where(v => v.AttributeInfo.IsHistorized)
|
||||
.Select(v => v.AttributeInfo.FullName)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
if (legacy.Count == 0)
|
||||
{
|
||||
Assert.Skip("dev Galaxy has no historized attributes — history routing parity unverified for this rig");
|
||||
}
|
||||
|
||||
legacy.Except(mxgw, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||
"every historized attribute discovered by the legacy backend must appear in the mxgw backend");
|
||||
mxgw.Except(legacy, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||
"every historized attribute discovered by the mxgw backend must appear in the legacy backend");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_new_Galaxy_backend_does_not_implement_IHistoryProvider_directly()
|
||||
{
|
||||
// Pinning the architectural decision from Phase 1 (PR 1.3): per-driver
|
||||
// IHistoryProvider was retired in favor of the server-owned HistoryRouter
|
||||
// for the *new* in-process GalaxyDriver. The legacy GalaxyProxyDriver
|
||||
// still surfaces IHistoryProvider for back-compat with the legacy server
|
||||
// bootstrap path (it's an accepted delta — the legacy driver retires in
|
||||
// PR 7.2 alongside the rest of the legacy projects). The architectural
|
||||
// pin we want to enforce is "the *new* path doesn't regress to per-driver
|
||||
// history".
|
||||
_h.RequireBoth();
|
||||
|
||||
(_h.MxGatewayDriver as IHistoryProvider).ShouldBeNull(
|
||||
"in-process GalaxyDriver must not surface IHistoryProvider — history routes through HistoryRouter");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// Side-by-side fixture that drives both the legacy <see cref="GalaxyProxyDriver"/>
|
||||
/// (talking to an out-of-process <c>OtOpcUa.Driver.Galaxy.Host.exe</c>) and the new
|
||||
/// in-process <see cref="GalaxyDriver"/> (talking to a running <c>mxaccessgw</c>
|
||||
/// gateway) against the same dev Galaxy. Phase 5 scenario tests use this harness
|
||||
/// to capture comparable snapshots from each backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per-backend availability is independent — a developer running just the legacy
|
||||
/// Galaxy.Host EXE without an mxaccessgw process up will see the legacy driver
|
||||
/// resolve and the mxgw driver mark itself unavailable. Each test decides how to
|
||||
/// handle partial availability:
|
||||
/// <list type="bullet">
|
||||
/// <item>Strict-parity tests call <see cref="RequireBoth"/> to skip when either side
|
||||
/// is missing.</item>
|
||||
/// <item>Single-backend smoke tests call <see cref="GetDriver"/> for the backend they
|
||||
/// care about and skip with the recorded <c>SkipReason</c>.</item>
|
||||
/// </list>
|
||||
/// Endpoint overrides come from environment variables so dev VMs and the central
|
||||
/// parity host can target the same suite without touching the test source:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OTOPCUA_PARITY_GW_ENDPOINT</c> — defaults to <c>http://localhost:5120</c>
|
||||
/// (mxaccessgw <c>launchSettings.json</c> http profile).</item>
|
||||
/// <item><c>OTOPCUA_PARITY_GW_API_KEY</c> — defaults to <c>parity-suite-key</c>.</item>
|
||||
/// <item><c>OTOPCUA_PARITY_CLIENT_NAME</c> — defaults to <c>OtOpcUa-Parity</c>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class ParityHarness : IAsyncLifetime
|
||||
{
|
||||
public enum Backend { LegacyHost, MxGateway }
|
||||
|
||||
private const string LegacySecret = "parity-suite-secret";
|
||||
private const string DefaultGwEndpoint = "http://localhost:5120";
|
||||
private const string DefaultGwApiKey = "parity-suite-key";
|
||||
private const string DefaultClientName = "OtOpcUa-Parity";
|
||||
|
||||
public IDriver? LegacyDriver { get; private set; }
|
||||
public string? LegacySkipReason { get; private set; }
|
||||
|
||||
public IDriver? MxGatewayDriver { get; private set; }
|
||||
public string? MxGatewaySkipReason { get; private set; }
|
||||
|
||||
private Process? _legacyHost;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
LegacySkipReason = "Windows-only";
|
||||
MxGatewaySkipReason = "Windows-only";
|
||||
return;
|
||||
}
|
||||
|
||||
await InitializeLegacyAsync();
|
||||
await InitializeMxGatewayAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Independent teardown — failure on one side must not prevent the other from
|
||||
// releasing its resources (esp. the legacy Host EXE subprocess).
|
||||
if (LegacyDriver is not null)
|
||||
{
|
||||
try { await LegacyDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||
(LegacyDriver as IDisposable)?.Dispose();
|
||||
LegacyDriver = null;
|
||||
}
|
||||
if (_legacyHost is not null && !_legacyHost.HasExited)
|
||||
{
|
||||
try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
try { _legacyHost.WaitForExit(5_000); } catch { /* ignore */ }
|
||||
}
|
||||
_legacyHost?.Dispose();
|
||||
_legacyHost = null;
|
||||
|
||||
if (MxGatewayDriver is not null)
|
||||
{
|
||||
try { await MxGatewayDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||
(MxGatewayDriver as IDisposable)?.Dispose();
|
||||
MxGatewayDriver = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Skip the test if either backend isn't available — strict-parity scenarios.</summary>
|
||||
public void RequireBoth()
|
||||
{
|
||||
if (LegacySkipReason is not null) Assert.Skip($"legacy backend unavailable: {LegacySkipReason}");
|
||||
if (MxGatewaySkipReason is not null) Assert.Skip($"mxgateway backend unavailable: {MxGatewaySkipReason}");
|
||||
}
|
||||
|
||||
/// <summary>Get a backend driver or skip if it's unavailable.</summary>
|
||||
public IDriver GetDriver(Backend backend)
|
||||
{
|
||||
return backend switch
|
||||
{
|
||||
Backend.LegacyHost when LegacyDriver is not null => LegacyDriver,
|
||||
Backend.LegacyHost => SkipAndThrow($"legacy backend unavailable: {LegacySkipReason}"),
|
||||
Backend.MxGateway when MxGatewayDriver is not null => MxGatewayDriver,
|
||||
Backend.MxGateway => SkipAndThrow($"mxgateway backend unavailable: {MxGatewaySkipReason}"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(backend), backend, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drive the same closure against every available backend. Tests use the
|
||||
/// returned dictionary to diff snapshots — keys are the backends that
|
||||
/// successfully resolved during <see cref="InitializeAsync"/>. If neither
|
||||
/// resolved, the result is empty and the test should skip.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<Backend, T>> RunOnAvailableAsync<T>(
|
||||
Func<IDriver, CancellationToken, Task<T>> scenario, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scenario);
|
||||
var results = new Dictionary<Backend, T>();
|
||||
if (LegacyDriver is not null)
|
||||
{
|
||||
results[Backend.LegacyHost] = await scenario(LegacyDriver, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (MxGatewayDriver is not null)
|
||||
{
|
||||
results[Backend.MxGateway] = await scenario(MxGatewayDriver, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private async Task InitializeLegacyAsync()
|
||||
{
|
||||
if (!await ZbReachableAsync())
|
||||
{
|
||||
LegacySkipReason = "Galaxy ZB SQL not reachable on localhost:1433";
|
||||
return;
|
||||
}
|
||||
var hostExe = FindLegacyHostExe();
|
||||
if (hostExe is null)
|
||||
{
|
||||
LegacySkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`";
|
||||
return;
|
||||
}
|
||||
|
||||
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!.Value;
|
||||
|
||||
var psi = new ProcessStartInfo(hostExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
EnvironmentVariables =
|
||||
{
|
||||
["OTOPCUA_GALAXY_PIPE"] = pipe,
|
||||
["OTOPCUA_ALLOWED_SID"] = sid,
|
||||
["OTOPCUA_GALAXY_SECRET"] = LegacySecret,
|
||||
// PR 5.W triage 2026-04-30: db-backend is Discover-only. The parity
|
||||
// matrix needs Read / Write / Subscribe over a real MxAccess session,
|
||||
// so use the mxaccess backend. ZB conn string is still consulted for
|
||||
// the discovery path (the mxaccess backend layers MxAccess on top of
|
||||
// the same DB).
|
||||
["OTOPCUA_GALAXY_BACKEND"] = "mxaccess",
|
||||
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
},
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_legacyHost = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
|
||||
await Task.Delay(2_000); // PipeServer warm-up — ParityFixture's settled value
|
||||
|
||||
var driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "parity-legacy",
|
||||
PipeName = pipe,
|
||||
SharedSecret = LegacySecret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
await driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
LegacyDriver = driver;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LegacySkipReason = $"legacy backend boot failed: {ex.Message}";
|
||||
if (_legacyHost is not null && !_legacyHost.HasExited)
|
||||
{
|
||||
try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeMxGatewayAsync()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_ENDPOINT") ?? DefaultGwEndpoint;
|
||||
var apiKey = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_API_KEY") ?? DefaultGwApiKey;
|
||||
var clientName = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_CLIENT_NAME") ?? DefaultClientName;
|
||||
|
||||
if (!await GwReachableAsync(endpoint))
|
||||
{
|
||||
MxGatewaySkipReason = $"mxaccessgw not reachable at {endpoint}";
|
||||
return;
|
||||
}
|
||||
|
||||
var configJson = $$"""
|
||||
{
|
||||
"Gateway": {
|
||||
"Endpoint": "{{endpoint}}",
|
||||
"ApiKeySecretRef": "{{apiKey}}",
|
||||
"UseTls": {{(endpoint.StartsWith("https") ? "true" : "false")}}
|
||||
},
|
||||
"MxAccess": { "ClientName": "{{clientName}}" }
|
||||
}
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("parity-mxgw", configJson);
|
||||
await driver.InitializeAsync(configJson, CancellationToken.None);
|
||||
MxGatewayDriver = driver;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MxGatewaySkipReason = $"mxgateway backend boot failed: {ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static IDriver SkipAndThrow(string reason)
|
||||
{
|
||||
Assert.Skip(reason);
|
||||
throw new UnreachableException(); // Assert.Skip throws SkipException; this satisfies the compiler
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 1433);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static async Task<bool> GwReachableAsync(string endpoint)
|
||||
{
|
||||
// Lightweight TCP probe — avoids spending the full gRPC connect timeout when the
|
||||
// gateway just isn't running. We can't validate the API-key handshake here without
|
||||
// doing a real RPC, so a successful TCP connect is the "available" signal and any
|
||||
// auth/protocol failure surfaces during InitializeAsync below.
|
||||
try
|
||||
{
|
||||
var uri = new Uri(endpoint, UriKind.Absolute);
|
||||
using var client = new TcpClient();
|
||||
var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80);
|
||||
var task = client.ConnectAsync(uri.Host, port);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static string? FindLegacyHostExe()
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var solutionRoot = asmDir;
|
||||
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
|
||||
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||
}
|
||||
if (solutionRoot is null) return null;
|
||||
|
||||
var path = Path.Combine(solutionRoot,
|
||||
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ParityCollection))]
|
||||
public sealed class ParityCollection : ICollectionFixture<ParityHarness> { }
|
||||
@@ -1,69 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.7 — Reconnect / disruption parity. After <see cref="IDriver.ReinitializeAsync"/>
|
||||
/// both backends must return to <see cref="DriverState.Healthy"/> and continue serving
|
||||
/// reads against the same Galaxy. Recovery time isn't pinned tightly because the
|
||||
/// legacy proxy reconnects the named pipe + Galaxy.Host's MxAccess client while the
|
||||
/// mxgw driver re-Registers the gateway session — different latencies are expected,
|
||||
/// but both must converge.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class ReconnectParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public ReconnectParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_returns_both_backends_to_Healthy()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
// Capture an initial read off both backends so we have a comparison baseline.
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||
var sample = b.Variables.Take(3).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||
|
||||
await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
await driver.ReinitializeAsync(driverConfigJson: "{}", ct);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy,
|
||||
$"{driver.DriverType} must return to Healthy after Reinitialize");
|
||||
return health.State;
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Reads must continue to succeed after reinit on both sides.
|
||||
var reads = await _h.RunOnAvailableAsync(
|
||||
(driver, ct) => ((IReadable)driver).ReadAsync(sample, ct),
|
||||
CancellationToken.None);
|
||||
|
||||
reads[ParityHarness.Backend.LegacyHost].Count.ShouldBe(sample.Length);
|
||||
reads[ParityHarness.Backend.MxGateway].Count.ShouldBe(sample.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_state_diverges_only_when_one_backend_is_in_recovery()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var legacyHealth = _h.LegacyDriver!.GetHealth().State;
|
||||
var mxgwHealth = _h.MxGatewayDriver!.GetHealth().State;
|
||||
|
||||
// Both backends were Healthy at end of InitializeAsync. If either has gone
|
||||
// Degraded, that's a real issue — surface it directly.
|
||||
legacyHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded);
|
||||
mxgwHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded);
|
||||
|
||||
// For now we don't pin them to be identical because the supervisor's
|
||||
// sampling cadence differs between backends. The 5.7 follow-up scenario
|
||||
// (when we introduce a toxiproxy-style fault injection) tightens this.
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// Same shape as <c>Driver.Galaxy.E2E.RecordingAddressSpaceBuilder</c>; duplicated
|
||||
/// here so the parity-tests project doesn't take a hard project reference on the
|
||||
/// E2E project (which would double-register E2E test classes during discovery).
|
||||
/// </summary>
|
||||
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<RecordedFolder> Folders { get; } = new();
|
||||
public List<RecordedVariable> Variables { get; } = new();
|
||||
public List<RecordedProperty> Properties { get; } = new();
|
||||
public List<RecordedAlarmCondition> AlarmConditions { get; } = new();
|
||||
public List<RecordedAlarmTransition> AlarmTransitions { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(new RecordedFolder(browseName, displayName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
|
||||
return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions, AlarmTransitions);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> Properties.Add(new RecordedProperty(browseName, dataType, value));
|
||||
|
||||
public sealed record RecordedFolder(string BrowseName, string DisplayName);
|
||||
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
|
||||
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
|
||||
public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info);
|
||||
public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args);
|
||||
|
||||
private sealed class RecordedVariableHandle(
|
||||
string fullReference,
|
||||
List<RecordedAlarmCondition> conditions,
|
||||
List<RecordedAlarmTransition> transitions) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullReference;
|
||||
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
{
|
||||
conditions.Add(new RecordedAlarmCondition(fullReference, info));
|
||||
return new RecordingSink(fullReference, transitions);
|
||||
}
|
||||
|
||||
private sealed class RecordingSink(
|
||||
string sourceNodeId, List<RecordedAlarmTransition> transitions) : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args)
|
||||
=> transitions.Add(new RecordedAlarmTransition(sourceNodeId, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.8 — Per-platform <c>ScanState</c> probe parity. The legacy backend's
|
||||
/// <c>GalaxyRuntimeProbeManager</c> and the in-process backend's
|
||||
/// <c>PerPlatformProbeWatcher</c> (PR 4.7) must surface the same per-host
|
||||
/// <see cref="HostConnectivityStatus"/> stream after Discover: same host name
|
||||
/// set, matching <see cref="HostState"/> per host.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class ScanStateProbeParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public ScanStateProbeParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_emits_same_host_set_after_Discover()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
// Probe-watcher membership only refreshes after a Discover pass — drive that
|
||||
// first so both backends have populated their per-platform tracker.
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
// Give the probe watcher a beat to land its initial ScanState reads —
|
||||
// PR 4.7 subscribes per platform with bufferedUpdateIntervalMs=0 so the
|
||||
// first push lands within ~publishingInterval (1s default).
|
||||
await Task.Delay(1_500, ct);
|
||||
return ((IHostConnectivityProbe)driver).GetHostStatuses();
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
// Legacy reports: client-name transport entry + every $WinPlatform/$AppEngine
|
||||
// probe. Mxgw reports the same shape (PR 4.7). The host-name set must agree
|
||||
// case-insensitively.
|
||||
var legacyHosts = legacy.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var mxgwHosts = mxgw.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (legacyHosts.Count == 0)
|
||||
{
|
||||
Assert.Skip("legacy backend reported no host probes — dev Galaxy may not be a multi-platform deployment");
|
||||
}
|
||||
|
||||
// The transport-entry host names differ by design — legacy uses the legacy
|
||||
// host's process-level identity, mxgw uses MxAccess.ClientName. Compare
|
||||
// only the platform-host subset (anything that's NOT either side's transport).
|
||||
var legacyPlatformHosts = legacyHosts.Where(h => !h.Contains("Galaxy.Host", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var mxgwPlatformHosts = mxgwHosts.Where(h => !h.Contains("OtOpcUa-Parity", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
legacyPlatformHosts.Except(mxgwPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||
"every $WinPlatform / $AppEngine probed by the legacy backend must appear in the mxgw probe set");
|
||||
mxgwPlatformHosts.Except(legacyPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||
"every $WinPlatform / $AppEngine probed by the mxgw backend must appear in the legacy probe set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_state_per_platform_matches_across_backends()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||
await Task.Delay(1_500, ct);
|
||||
return ((IHostConnectivityProbe)driver).GetHostStatuses()
|
||||
.ToDictionary(s => s.HostName, s => s.State, StringComparer.OrdinalIgnoreCase);
|
||||
}, CancellationToken.None);
|
||||
|
||||
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||
|
||||
if (legacy.Count == 0 || mxgw.Count == 0)
|
||||
{
|
||||
Assert.Skip("one or both backends reported no host probes");
|
||||
}
|
||||
|
||||
// Skip the transport entry per backend (different by design); compare the
|
||||
// platform-host overlap.
|
||||
var commonHosts = legacy.Keys.Intersect(mxgw.Keys, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
if (commonHosts.Length == 0)
|
||||
{
|
||||
Assert.Skip("no overlapping platform hosts between backends — likely the transport names differ but no $WinPlatform was discovered");
|
||||
}
|
||||
|
||||
foreach (var host in commonHosts)
|
||||
{
|
||||
mxgw[host].ShouldBe(legacy[host], $"HostState parity for '{host}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 6.4 — long-running soak scenario for the in-process Galaxy driver against a
|
||||
/// live mxaccessgw. Subscribes a configurable tag count, holds the subscription
|
||||
/// for a configurable duration, polls the EventPump's three counters
|
||||
/// (<c>galaxy.events.received</c> / <c>galaxy.events.dispatched</c> /
|
||||
/// <c>galaxy.events.dropped</c>) every minute, and asserts:
|
||||
/// <list type="bullet">
|
||||
/// <item>events.received continues to grow (the gw stream isn't stuck)</item>
|
||||
/// <item>events.dropped stays under a configurable ceiling</item>
|
||||
/// <item>process working-set size doesn't grow unboundedly (leak guard)</item>
|
||||
/// </list>
|
||||
/// Always skipped unless the operator opts in via <c>OTOPCUA_SOAK_RUN=1</c> and
|
||||
/// the mxgw backend is reachable. The default scenario size is 50k tags / 24h
|
||||
/// per the PR plan; both are env-overridable so a smoke run can shorten them
|
||||
/// to a few minutes for CI.
|
||||
/// </summary>
|
||||
[Trait("Category", "Soak")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class SoakScenarioTests
|
||||
{
|
||||
private const string MeterName = "ZB.MOM.WW.OtOpcUa.Driver.Galaxy";
|
||||
|
||||
private readonly ParityHarness _h;
|
||||
public SoakScenarioTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Soak_HoldsSubscription_AndKeepsEventStreamFlowing()
|
||||
{
|
||||
var run = Environment.GetEnvironmentVariable("OTOPCUA_SOAK_RUN");
|
||||
if (!string.Equals(run, "1", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.Skip("set OTOPCUA_SOAK_RUN=1 to run the 50k-tag soak (default 24h, override OTOPCUA_SOAK_MINUTES + OTOPCUA_SOAK_TAGS for CI)");
|
||||
}
|
||||
if (_h.MxGatewayDriver is null)
|
||||
{
|
||||
Assert.Skip($"mxgateway backend unavailable: {_h.MxGatewaySkipReason}");
|
||||
}
|
||||
|
||||
var tagCount = ParseInt("OTOPCUA_SOAK_TAGS", 50_000);
|
||||
var soakMinutes = ParseInt("OTOPCUA_SOAK_MINUTES", 24 * 60);
|
||||
var dropCeilingPercent = ParseDouble("OTOPCUA_SOAK_DROP_PCT", 0.5); // 0.5% drop ceiling
|
||||
|
||||
// Discover and pick a sample. If the live Galaxy doesn't have tagCount tags,
|
||||
// fall back to whatever's available — soak diagnostics still apply.
|
||||
var driver = _h.MxGatewayDriver!;
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
var sample = b.Variables.Take(tagCount)
|
||||
.Select(v => v.AttributeInfo.FullName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (sample.Length == 0) Assert.Skip("dev Galaxy reported zero discoverable variables — nothing to soak");
|
||||
|
||||
// Capture the three EventPump counters via MeterListener so we can poll
|
||||
// their cumulative totals once per minute.
|
||||
var snapshot = new CounterSnapshot();
|
||||
using var listener = new MeterListener();
|
||||
listener.InstrumentPublished = (instr, l) =>
|
||||
{
|
||||
if (instr.Meter.Name == MeterName) l.EnableMeasurementEvents(instr);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((instr, value, _, _) =>
|
||||
{
|
||||
switch (instr.Name)
|
||||
{
|
||||
case "galaxy.events.received": Interlocked.Add(ref snapshot._received, value); break;
|
||||
case "galaxy.events.dispatched": Interlocked.Add(ref snapshot._dispatched, value); break;
|
||||
case "galaxy.events.dropped": Interlocked.Add(ref snapshot._dropped, value); break;
|
||||
}
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var initialWorkingSet = Process.GetCurrentProcess().WorkingSet64;
|
||||
var startedUtc = DateTime.UtcNow;
|
||||
var deadline = startedUtc + TimeSpan.FromMinutes(soakMinutes);
|
||||
|
||||
var handle = await ((ISubscribable)driver)
|
||||
.SubscribeAsync(sample, TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
try
|
||||
{
|
||||
// Per-minute poll loop — pin the invariants and produce a CSV-style
|
||||
// log row so an operator can grep the test runner's stdout.
|
||||
var lastReceived = 0L;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||
var elapsed = DateTime.UtcNow - startedUtc;
|
||||
var ws = Process.GetCurrentProcess().WorkingSet64;
|
||||
Console.WriteLine(
|
||||
$"soak,{elapsed.TotalMinutes:F1},received={snapshot.Received},dispatched={snapshot.Dispatched},dropped={snapshot.Dropped},ws_mb={ws / 1024 / 1024}");
|
||||
|
||||
snapshot.Received.ShouldBeGreaterThan(lastReceived,
|
||||
$"events.received did not grow over the last minute (elapsed={elapsed:hh\\:mm\\:ss}) — gw stream may be stuck");
|
||||
lastReceived = snapshot.Received;
|
||||
|
||||
var droppedPct = snapshot.Received == 0
|
||||
? 0.0
|
||||
: 100.0 * snapshot.Dropped / snapshot.Received;
|
||||
droppedPct.ShouldBeLessThan(dropCeilingPercent,
|
||||
$"events.dropped ratio {droppedPct:F2}% exceeded {dropCeilingPercent:F2}% ceiling at {elapsed:hh\\:mm\\:ss}");
|
||||
|
||||
// Working-set guard: if the process grew >1 GB above the initial
|
||||
// baseline, surface it. This is generous — a hot subscription stream
|
||||
// legitimately uses memory; we're catching unbounded leaks, not
|
||||
// steady-state allocation.
|
||||
((ws - initialWorkingSet) / (1024L * 1024L * 1024L))
|
||||
.ShouldBeLessThan(1L,
|
||||
$"working set grew >1 GB above baseline at {elapsed:hh\\:mm\\:ss} — possible leak");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ((ISubscribable)driver).UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseInt(string name, int defaultValue) =>
|
||||
int.TryParse(Environment.GetEnvironmentVariable(name), out var v) ? v : defaultValue;
|
||||
private static double ParseDouble(string name, double defaultValue) =>
|
||||
double.TryParse(Environment.GetEnvironmentVariable(name), out var v) ? v : defaultValue;
|
||||
|
||||
private sealed class CounterSnapshot
|
||||
{
|
||||
internal long _received, _dispatched, _dropped;
|
||||
public long Received => Interlocked.Read(ref _received);
|
||||
public long Dispatched => Interlocked.Read(ref _dispatched);
|
||||
public long Dropped => Interlocked.Read(ref _dropped);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.3 — Subscribe + event-rate parity. Both backends must accept the same
|
||||
/// full-reference list, return a usable subscription handle, and dispatch a
|
||||
/// similar number of OnDataChange events for the same observation window.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class SubscribeAndEventRateParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public SubscribeAndEventRateParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_returns_a_handle_for_each_backend()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var sample = await PickSampleAsync(5);
|
||||
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||
|
||||
var handles = await _h.RunOnAvailableAsync(
|
||||
(driver, ct) => ((ISubscribable)driver).SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), ct),
|
||||
CancellationToken.None);
|
||||
|
||||
handles[ParityHarness.Backend.LegacyHost].ShouldNotBeNull();
|
||||
handles[ParityHarness.Backend.MxGateway].ShouldNotBeNull();
|
||||
|
||||
// Clean up so we don't leave dangling advises in either backend.
|
||||
foreach (var (backend, handle) in handles)
|
||||
{
|
||||
await ((ISubscribable)_h.GetDriver(backend))
|
||||
.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_event_rate_within_tolerance_for_a_3s_window()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var sample = await PickSampleAsync(5);
|
||||
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||
|
||||
var counts = new Dictionary<ParityHarness.Backend, int>();
|
||||
var subs = new Dictionary<ParityHarness.Backend, ISubscriptionHandle>();
|
||||
try
|
||||
{
|
||||
foreach (var backend in new[] { ParityHarness.Backend.LegacyHost, ParityHarness.Backend.MxGateway })
|
||||
{
|
||||
var driver = _h.GetDriver(backend);
|
||||
var local = 0;
|
||||
EventHandler<DataChangeEventArgs> handler = (_, _) => Interlocked.Increment(ref local);
|
||||
((ISubscribable)driver).OnDataChange += handler;
|
||||
var handle = await ((ISubscribable)driver)
|
||||
.SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), CancellationToken.None);
|
||||
subs[backend] = handle;
|
||||
|
||||
await Task.Delay(3_000, TestContext.Current.CancellationToken);
|
||||
|
||||
((ISubscribable)driver).OnDataChange -= handler;
|
||||
counts[backend] = Volatile.Read(ref local);
|
||||
}
|
||||
|
||||
// Tolerance is generous because both backends are looking at the same
|
||||
// physical Galaxy; the gateway's StreamEvents pump and the legacy
|
||||
// OnDataChange COM advises are fed by the same MXAccess subscriptions
|
||||
// upstream. ±50% absorbs scheduler jitter without hiding a wholesale
|
||||
// event-rate regression.
|
||||
var legacyCount = counts[ParityHarness.Backend.LegacyHost];
|
||||
var mxgwCount = counts[ParityHarness.Backend.MxGateway];
|
||||
if (legacyCount + mxgwCount == 0)
|
||||
{
|
||||
Assert.Skip("no value changes observed in 3s window — sample may be all static configuration tags");
|
||||
}
|
||||
var ratio = (double)mxgwCount / Math.Max(legacyCount, 1);
|
||||
ratio.ShouldBeInRange(0.5, 1.5,
|
||||
$"event-rate parity within ±50%: legacy={legacyCount}, mxgw={mxgwCount}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var (backend, handle) in subs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ((ISubscribable)_h.GetDriver(backend))
|
||||
.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string[]> PickSampleAsync(int count)
|
||||
{
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||
return b.Variables.Take(count).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.4 — Write-by-classification parity. Each driver routes writes by the
|
||||
/// attribute's <see cref="SecurityClassification"/>: <c>FreeAccess</c> /
|
||||
/// <c>Operate</c> use plain <c>Write</c>; <c>Tune</c> / <c>Configure</c> /
|
||||
/// <c>VerifiedWrite</c> use <c>WriteSecured</c>. Both backends must surface the
|
||||
/// same StatusCode for the same write request — successful for FreeAccess /
|
||||
/// Operate (assuming the dev Galaxy has at least one writable attribute) and
|
||||
/// failure for Configure when no auth principal is supplied.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class WriteByClassificationParityTests
|
||||
{
|
||||
private readonly ParityHarness _h;
|
||||
public WriteByClassificationParityTests(ParityHarness h) => _h = h;
|
||||
|
||||
[Fact]
|
||||
public async Task FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
var target = b.Variables.FirstOrDefault(v =>
|
||||
v.AttributeInfo.SecurityClass is SecurityClassification.FreeAccess or SecurityClassification.Operate
|
||||
&& v.AttributeInfo.DriverDataType is DriverDataType.Float32 or DriverDataType.Float64 or DriverDataType.Int32);
|
||||
if (target is null) Assert.Skip("no FreeAccess/Operate numeric writable attribute on dev Galaxy");
|
||||
|
||||
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
||||
var results = await _h.RunOnAvailableAsync(
|
||||
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
||||
CancellationToken.None);
|
||||
|
||||
var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode;
|
||||
var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode;
|
||||
AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Configure_class_write_routes_through_secured_path_on_both_backends()
|
||||
{
|
||||
_h.RequireBoth();
|
||||
|
||||
var b = new RecordingAddressSpaceBuilder();
|
||||
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||
|
||||
var target = b.Variables.FirstOrDefault(v =>
|
||||
v.AttributeInfo.SecurityClass is SecurityClassification.Configure or SecurityClassification.Tune);
|
||||
if (target is null) Assert.Skip("no Configure/Tune attribute on dev Galaxy");
|
||||
|
||||
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
||||
var results = await _h.RunOnAvailableAsync(
|
||||
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
||||
CancellationToken.None);
|
||||
|
||||
// Both backends route through the secured-write path. The exact StatusCode
|
||||
// depends on whether the running test identity has write permission on the
|
||||
// dev Galaxy — what matters here is that they agree on the status *class*
|
||||
// (Good vs Bad vs Uncertain), not which exact code they produce.
|
||||
var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode;
|
||||
var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode;
|
||||
AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pin the parity invariant that *matters*: both backends classify the same
|
||||
/// write outcome as Good / Uncertain / Bad. The exact OPC UA code can diverge
|
||||
/// because legacy <c>MxAccessGalaxyBackend</c> flat-maps every failure to
|
||||
/// <c>BadInternalError</c> while the new <c>GatewayGalaxyDataWriter</c> uses
|
||||
/// <c>MxStatusProxy.RawDetectedBy</c> to distinguish gateway-layer faults
|
||||
/// (<c>BadCommunicationError</c>) from MxAccess HRESULT faults — see
|
||||
/// <c>docs/v2/Galaxy.ParityMatrix.md</c> "Accepted deltas". Tighter mapping
|
||||
/// parity isn't worth investing in: legacy retires in PR 7.2.
|
||||
/// </summary>
|
||||
private static void AssertStatusClassMatches(uint legacyCode, uint mxgwCode, string tag)
|
||||
{
|
||||
IsBadStatus(legacyCode).ShouldBe(IsBadStatus(mxgwCode),
|
||||
$"status-class (Bad) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}");
|
||||
IsGoodStatus(legacyCode).ShouldBe(IsGoodStatus(mxgwCode),
|
||||
$"status-class (Good) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}");
|
||||
}
|
||||
|
||||
private static bool IsBadStatus(uint code) => (code & 0xC0000000u) == 0x80000000u;
|
||||
private static bool IsGoodStatus(uint code) => (code & 0xC0000000u) == 0x00000000u;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Both backends are referenced so a single test class can exercise the same
|
||||
scenario against both and diff the results. The legacy GalaxyProxyDriver
|
||||
spawns an out-of-process Galaxy.Host EXE; the new GalaxyDriver speaks to
|
||||
the mxaccessgw gRPC gateway. See ParityHarness for the discovery + skip rules.
|
||||
-->
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,27 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AggregateColumnMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average, "Average")]
|
||||
[InlineData(HistoryAggregateType.Minimum, "Minimum")]
|
||||
[InlineData(HistoryAggregateType.Maximum, "Maximum")]
|
||||
[InlineData(HistoryAggregateType.Count, "ValueCount")]
|
||||
public void Maps_OpcUa_enum_to_AnalogSummary_column(HistoryAggregateType aggregate, string expected)
|
||||
{
|
||||
GalaxyProxyDriver.MapAggregateToColumn(aggregate).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Total_is_not_supported()
|
||||
{
|
||||
Should.Throw<System.NotSupportedException>(
|
||||
() => GalaxyProxyDriver.MapAggregateToColumn(HistoryAggregateType.Total));
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BackoffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_sequence_is_5_15_60_seconds_capped()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(60), "capped once past the last entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordStableRun_resets_to_the_first_delay()
|
||||
{
|
||||
var b = new Backoff();
|
||||
b.Next(); b.Next();
|
||||
b.RecordStableRun();
|
||||
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CircuitBreakerTests
|
||||
{
|
||||
[Fact]
|
||||
public void First_three_crashes_within_window_allow_respawn()
|
||||
{
|
||||
var breaker = new CircuitBreaker();
|
||||
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
breaker.TryRecordCrash(t0, out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(t0.AddSeconds(30), out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(t0.AddSeconds(60), out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fourth_crash_within_window_opens_breaker_with_sticky_alert()
|
||||
{
|
||||
var breaker = new CircuitBreaker();
|
||||
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (var i = 0; i < 3; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
|
||||
|
||||
breaker.TryRecordCrash(t0.AddSeconds(120), out var remaining).ShouldBeFalse();
|
||||
remaining.ShouldBe(TimeSpan.FromHours(1));
|
||||
breaker.StickyAlertActive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cooldown_escalates_1h_then_4h_then_manual()
|
||||
{
|
||||
var breaker = new CircuitBreaker();
|
||||
var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Open once.
|
||||
for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
|
||||
|
||||
// Cooldown starts when the breaker opens (the 4th crash, at t0+90s). Jump past 1h from there.
|
||||
var openedAt = t0.AddSeconds(90);
|
||||
var afterFirstCooldown = openedAt.AddHours(1).AddMinutes(1);
|
||||
breaker.TryRecordCrash(afterFirstCooldown, out _).ShouldBeTrue("cooldown elapsed, breaker closes for a try");
|
||||
|
||||
// Second trip: within 5 min, breaker opens again with 4h cooldown. The crash that trips
|
||||
// it is the 3rd retry since the cooldown closed (afterFirstCooldown itself counted as 1).
|
||||
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(30), out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(60), out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(90), out var cd2).ShouldBeFalse(
|
||||
"4th crash within window reopens the breaker");
|
||||
cd2.ShouldBe(TimeSpan.FromHours(4));
|
||||
|
||||
// Third trip: 4h elapsed, breaker closes for a try, then reopens with MaxValue (manual only).
|
||||
var reopenedAt = afterFirstCooldown.AddSeconds(90);
|
||||
var afterSecondCooldown = reopenedAt.AddHours(4).AddMinutes(1);
|
||||
breaker.TryRecordCrash(afterSecondCooldown, out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(30), out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(60), out _).ShouldBeTrue();
|
||||
breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(90), out var cd3).ShouldBeFalse();
|
||||
cd3.ShouldBe(TimeSpan.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualReset_clears_sticky_alert_and_crash_history()
|
||||
{
|
||||
var breaker = new CircuitBreaker();
|
||||
var t0 = DateTime.UtcNow;
|
||||
for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _);
|
||||
|
||||
breaker.ManualReset();
|
||||
breaker.StickyAlertActive.ShouldBeFalse();
|
||||
|
||||
breaker.TryRecordCrash(t0.AddMinutes(10), out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up #247 — covers the wire-format translation between the
|
||||
/// <see cref="AlarmHistorianEvent"/> the SQLite sink hands to the writer + the
|
||||
/// <see cref="HistorianAlarmEventDto"/> the Galaxy.Host IPC contract expects, plus
|
||||
/// the per-event outcome enum mapping. Pure functions; the round-trip over a real
|
||||
/// pipe is exercised by the live Host suite (task #240).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyHistorianWriterMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToDto_round_trips_every_field()
|
||||
{
|
||||
var ts = new DateTime(2026, 4, 20, 14, 30, 0, DateTimeKind.Utc);
|
||||
var e = new AlarmHistorianEvent(
|
||||
AlarmId: "al-7",
|
||||
EquipmentPath: "/Site/Line/Cell",
|
||||
AlarmName: "HighTemp",
|
||||
AlarmTypeName: "LimitAlarm",
|
||||
Severity: AlarmSeverity.High,
|
||||
EventKind: "RaiseEvent",
|
||||
Message: "Temp 92°C exceeded 90°C",
|
||||
User: "operator-7",
|
||||
Comment: "ack with reason",
|
||||
TimestampUtc: ts);
|
||||
|
||||
var dto = GalaxyHistorianWriter.ToDto(e);
|
||||
|
||||
dto.AlarmId.ShouldBe("al-7");
|
||||
dto.EquipmentPath.ShouldBe("/Site/Line/Cell");
|
||||
dto.AlarmName.ShouldBe("HighTemp");
|
||||
dto.AlarmTypeName.ShouldBe("LimitAlarm");
|
||||
dto.Severity.ShouldBe((int)AlarmSeverity.High);
|
||||
dto.EventKind.ShouldBe("RaiseEvent");
|
||||
dto.Message.ShouldBe("Temp 92°C exceeded 90°C");
|
||||
dto.User.ShouldBe("operator-7");
|
||||
dto.Comment.ShouldBe("ack with reason");
|
||||
dto.TimestampUtcUnixMs.ShouldBe(new DateTimeOffset(ts, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_preserves_null_Comment()
|
||||
{
|
||||
var e = new AlarmHistorianEvent(
|
||||
"a", "/p", "n", "AlarmCondition", AlarmSeverity.Low, "RaiseEvent", "m",
|
||||
User: "system", Comment: null, TimestampUtc: DateTime.UtcNow);
|
||||
|
||||
GalaxyHistorianWriter.ToDto(e).Comment.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistorianAlarmEventOutcomeDto.Ack, HistorianWriteOutcome.Ack)]
|
||||
[InlineData(HistorianAlarmEventOutcomeDto.RetryPlease, HistorianWriteOutcome.RetryPlease)]
|
||||
[InlineData(HistorianAlarmEventOutcomeDto.PermanentFail, HistorianWriteOutcome.PermanentFail)]
|
||||
public void MapOutcome_round_trips_every_byte(
|
||||
HistorianAlarmEventOutcomeDto wire, HistorianWriteOutcome expected)
|
||||
{
|
||||
GalaxyHistorianWriter.MapOutcome(wire).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapOutcome_unknown_byte_throws()
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => GalaxyHistorianWriter.MapOutcome((HistorianAlarmEventOutcomeDto)0xFF));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_client_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new GalaxyHistorianWriter(null!));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the single-pending-slot router in <see cref="GalaxyIpcClient"/>: request/response
|
||||
/// matching, <see cref="MessageKind.ErrorResponse"/> handling, and routing of unsolicited push
|
||||
/// frames (e.g. <see cref="MessageKind.RuntimeStatusChange"/>) arriving between a request and
|
||||
/// its response. Without the router, a push event interleaved with a call would be consumed
|
||||
/// as the response and the next <see cref="GalaxyIpcClient.CallAsync{TReq, TResp}"/> would
|
||||
/// fail with an "Expected X, got Y" mismatch — the bug that blocked task #112's live Galaxy
|
||||
/// E2E on the dev box.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyIpcClientRoutingTests
|
||||
{
|
||||
private const string Secret = "routing-suite-secret";
|
||||
|
||||
[Fact]
|
||||
public async Task Response_matching_expected_kind_completes_the_call()
|
||||
{
|
||||
var (pipe, serverStream, clientTask) = await StartPairAsync();
|
||||
|
||||
using (serverStream)
|
||||
await using (var client = await clientTask)
|
||||
{
|
||||
using var reader = new FrameReader(serverStream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverStream, leaveOpen: true);
|
||||
|
||||
var callTask = client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse,
|
||||
CancellationToken.None);
|
||||
|
||||
var request = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
request!.Value.Kind.ShouldBe(MessageKind.OpenSessionRequest);
|
||||
|
||||
await writer.WriteAsync(MessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 42 },
|
||||
CancellationToken.None);
|
||||
|
||||
var response = await callTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
response.Success.ShouldBeTrue();
|
||||
response.SessionId.ShouldBe(42);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorResponse_throws_GalaxyIpcException_regardless_of_expected_kind()
|
||||
{
|
||||
var (pipe, serverStream, clientTask) = await StartPairAsync();
|
||||
|
||||
using (serverStream)
|
||||
await using (var client = await clientTask)
|
||||
{
|
||||
using var reader = new FrameReader(serverStream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverStream, leaveOpen: true);
|
||||
|
||||
var callTask = client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse,
|
||||
CancellationToken.None);
|
||||
|
||||
await reader.ReadFrameAsync(CancellationToken.None);
|
||||
await writer.WriteAsync(MessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "bad-request", Message = "malformed" },
|
||||
CancellationToken.None);
|
||||
|
||||
var ex = await Should.ThrowAsync<GalaxyIpcException>(() => callTask.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
ex.Code.ShouldBe("bad-request");
|
||||
ex.Message.ShouldContain("malformed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsolicited_event_between_request_and_response_routes_to_handler_not_the_call()
|
||||
{
|
||||
var (pipe, serverStream, clientTask) = await StartPairAsync();
|
||||
|
||||
using (serverStream)
|
||||
await using (var client = await clientTask)
|
||||
{
|
||||
var eventFrames = new List<(MessageKind Kind, byte[] Body)>();
|
||||
var eventReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.SetEventHandler((k, body) =>
|
||||
{
|
||||
eventFrames.Add((k, body));
|
||||
if (k == MessageKind.RuntimeStatusChange) eventReceived.TrySetResult(true);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
using var reader = new FrameReader(serverStream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverStream, leaveOpen: true);
|
||||
|
||||
var callTask = client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse,
|
||||
CancellationToken.None);
|
||||
|
||||
await reader.ReadFrameAsync(CancellationToken.None);
|
||||
|
||||
// Push event lands first — the bug this test guards against is CallAsync consuming
|
||||
// this frame as the response and failing with "Expected X, got Y".
|
||||
await writer.WriteAsync(MessageKind.RuntimeStatusChange,
|
||||
new RuntimeStatusChangeNotification
|
||||
{
|
||||
Status = new HostConnectivityStatus
|
||||
{
|
||||
HostName = "host-a", RuntimeStatus = "Running", LastObservedUtcUnixMs = 1,
|
||||
},
|
||||
}, CancellationToken.None);
|
||||
|
||||
await writer.WriteAsync(MessageKind.OpenSessionResponse,
|
||||
new OpenSessionResponse { Success = true, SessionId = 7 },
|
||||
CancellationToken.None);
|
||||
|
||||
var response = await callTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
response.SessionId.ShouldBe(7);
|
||||
|
||||
await eventReceived.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var runtime = eventFrames.ShouldHaveSingleItem();
|
||||
runtime.Kind.ShouldBe(MessageKind.RuntimeStatusChange);
|
||||
var decoded = MessagePackSerializer.Deserialize<RuntimeStatusChangeNotification>(runtime.Body);
|
||||
decoded.Status.HostName.ShouldBe("host-a");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_push_event_with_no_pending_call_still_reaches_handler()
|
||||
{
|
||||
var (pipe, serverStream, clientTask) = await StartPairAsync();
|
||||
|
||||
using (serverStream)
|
||||
await using (var client = await clientTask)
|
||||
{
|
||||
var received = new TaskCompletionSource<(MessageKind, byte[])>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.SetEventHandler((k, body) => { received.TrySetResult((k, body)); return Task.CompletedTask; });
|
||||
|
||||
using var writer = new FrameWriter(serverStream, leaveOpen: true);
|
||||
await writer.WriteAsync(MessageKind.HostConnectivityStatus,
|
||||
new HostConnectivityStatus { HostName = "h", RuntimeStatus = "Running", LastObservedUtcUnixMs = 1 },
|
||||
CancellationToken.None);
|
||||
|
||||
var (kind, _) = await received.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
kind.ShouldBe(MessageKind.HostConnectivityStatus);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Peer_closing_pipe_during_pending_call_surfaces_as_EndOfStream()
|
||||
{
|
||||
var (pipe, serverStream, clientTask) = await StartPairAsync();
|
||||
|
||||
await using var client = await clientTask;
|
||||
|
||||
using var reader = new FrameReader(serverStream, leaveOpen: true);
|
||||
|
||||
var callTask = client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "t", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse,
|
||||
CancellationToken.None);
|
||||
|
||||
await reader.ReadFrameAsync(CancellationToken.None);
|
||||
serverStream.Dispose();
|
||||
|
||||
await Should.ThrowAsync<EndOfStreamException>(() => callTask.WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
// ---- test harness ----------------------------------------------------
|
||||
|
||||
private static async Task<(string PipeName, NamedPipeServerStream Server, Task<GalaxyIpcClient> Client)> StartPairAsync()
|
||||
{
|
||||
var pipeName = $"GalaxyIpcRouting-{Guid.NewGuid():N}";
|
||||
var serverStream = new NamedPipeServerStream(
|
||||
pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
|
||||
// Drive a Hello/HelloAck handshake on a background task so the client's ConnectAsync
|
||||
// can complete. After the handshake the test owns the stream for manual framing.
|
||||
var acceptTask = Task.Run(async () =>
|
||||
{
|
||||
await serverStream.WaitForConnectionAsync();
|
||||
using var reader = new FrameReader(serverStream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(serverStream, leaveOpen: true);
|
||||
|
||||
var hello = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
if (hello is null || hello.Value.Kind != MessageKind.Hello)
|
||||
throw new InvalidOperationException("expected Hello first");
|
||||
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = "test-host" },
|
||||
CancellationToken.None);
|
||||
});
|
||||
|
||||
var clientTask = GalaxyIpcClient.ConnectAsync(pipeName, Secret, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
await acceptTask;
|
||||
return (pipeName, serverStream, clientTask);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeartbeatMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Single_miss_does_not_declare_dead()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_consecutive_misses_declare_host_dead()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ack_resets_the_miss_counter()
|
||||
{
|
||||
var m = new HeartbeatMonitor();
|
||||
m.RecordMiss();
|
||||
m.RecordMiss();
|
||||
|
||||
m.RecordAck(DateTime.UtcNow);
|
||||
|
||||
m.ConsecutiveMisses.ShouldBe(0);
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
m.RecordMiss().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="GalaxyProxyDriver.ToHistoricalEvent"/> — the wire-to-domain mapping
|
||||
/// from <see cref="GalaxyHistoricalEvent"/> (MessagePack-annotated IPC contract,
|
||||
/// Unix-ms timestamps) to <c>Core.Abstractions.HistoricalEvent</c> (domain record,
|
||||
/// <see cref="DateTime"/> timestamps). Added in PR 35 alongside the new
|
||||
/// <c>IHistoryProvider.ReadEventsAsync</c> method.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoricalEventMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_every_field_from_wire_to_domain_record()
|
||||
{
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "evt-42",
|
||||
SourceName = "Tank1.HiAlarm",
|
||||
EventTimeUtcUnixMs = 1_700_000_000_000L, // 2023-11-14T22:13:20.000Z
|
||||
ReceivedTimeUtcUnixMs = 1_700_000_000_500L,
|
||||
DisplayText = "High level reached",
|
||||
Severity = 750,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventId.ShouldBe("evt-42");
|
||||
domain.SourceName.ShouldBe("Tank1.HiAlarm");
|
||||
domain.EventTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc));
|
||||
domain.ReceivedTimeUtc.ShouldBe(new DateTime(2023, 11, 14, 22, 13, 20, 500, DateTimeKind.Utc));
|
||||
domain.Message.ShouldBe("High level reached");
|
||||
domain.Severity.ShouldBe((ushort)750);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preserves_null_SourceName_and_DisplayText()
|
||||
{
|
||||
// Historical rows from the Galaxy event historian often omit source or message for
|
||||
// system events (e.g. time sync). The mapping must preserve null — callers use it to
|
||||
// distinguish system events from alarm events.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "sys-1",
|
||||
SourceName = null,
|
||||
EventTimeUtcUnixMs = 0,
|
||||
ReceivedTimeUtcUnixMs = 0,
|
||||
DisplayText = null,
|
||||
Severity = 1,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.SourceName.ShouldBeNull();
|
||||
domain.Message.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventTime_and_ReceivedTime_are_produced_as_DateTimeKind_Utc()
|
||||
{
|
||||
// Unix-ms timestamps come off the wire timezone-agnostic; the mapping must tag the
|
||||
// resulting DateTime as Utc so downstream serializers (JSON, OPC UA types) don't apply
|
||||
// an unexpected local-time offset.
|
||||
var wire = new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = "e",
|
||||
EventTimeUtcUnixMs = 1_000L,
|
||||
ReceivedTimeUtcUnixMs = 2_000L,
|
||||
};
|
||||
|
||||
var domain = GalaxyProxyDriver.ToHistoricalEvent(wire);
|
||||
|
||||
domain.EventTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
domain.ReceivedTimeUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The honest cross-FX parity test — spawns the actual <c>OtOpcUa.Driver.Galaxy.Host.exe</c>
|
||||
/// subprocess (net48 x86), the Proxy connects via real named pipe, exercises Discover
|
||||
/// against the live Galaxy ZB DB, and asserts gobjects come back. This is the production
|
||||
/// deployment shape (Tier C: separate process, IPC over named pipe, Proxy in the .NET 10
|
||||
/// server process). Skipped when the Host EXE isn't built or Galaxy is unreachable.
|
||||
/// </summary>
|
||||
[Trait("Category", "ProcessSpawnParity")]
|
||||
public sealed class HostSubprocessParityTests : IDisposable
|
||||
{
|
||||
private Process? _hostProcess;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_hostProcess is not null && !_hostProcess.HasExited)
|
||||
{
|
||||
try { _hostProcess.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
try { _hostProcess.WaitForExit(5_000); } catch { /* ignore */ }
|
||||
}
|
||||
_hostProcess?.Dispose();
|
||||
}
|
||||
|
||||
private static string? FindHostExe()
|
||||
{
|
||||
// The test assembly lives at tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/bin/Debug/net10.0/.
|
||||
// The Host EXE lives at src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/bin/Debug/net48/.
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var solutionRoot = asmDir;
|
||||
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
break;
|
||||
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||
}
|
||||
if (solutionRoot is null) return null;
|
||||
|
||||
var candidate = Path.Combine(solutionRoot,
|
||||
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Sockets.TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 1433);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spawned_Host_in_db_mode_lets_Proxy_Discover_real_Galaxy_gobjects()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
if (!await ZbReachableAsync()) return;
|
||||
|
||||
var hostExe = FindHostExe();
|
||||
if (hostExe is null) return; // skip when the Host hasn't been built
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
var pipeName = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||
const string secret = "parity-secret";
|
||||
|
||||
var psi = new ProcessStartInfo(hostExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
EnvironmentVariables =
|
||||
{
|
||||
["OTOPCUA_GALAXY_PIPE"] = pipeName,
|
||||
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
||||
["OTOPCUA_GALAXY_SECRET"] = secret,
|
||||
["OTOPCUA_GALAXY_BACKEND"] = "db", // SQL-only — doesn't need MXAccess
|
||||
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
},
|
||||
};
|
||||
|
||||
_hostProcess = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host");
|
||||
|
||||
// Wait for the pipe to come up — the Host's PipeServer takes ~100ms to bind.
|
||||
await Task.Delay(2_000);
|
||||
|
||||
await using var client = await GalaxyIpcClient.ConnectAsync(
|
||||
pipeName, secret, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var sessionResp = await client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = "parity", DriverConfigJson = "{}" },
|
||||
MessageKind.OpenSessionResponse,
|
||||
CancellationToken.None);
|
||||
sessionResp.Success.ShouldBeTrue(sessionResp.Error);
|
||||
|
||||
var discoverResp = await client.CallAsync<DiscoverHierarchyRequest, DiscoverHierarchyResponse>(
|
||||
MessageKind.DiscoverHierarchyRequest,
|
||||
new DiscoverHierarchyRequest { SessionId = sessionResp.SessionId },
|
||||
MessageKind.DiscoverHierarchyResponse,
|
||||
CancellationToken.None);
|
||||
|
||||
discoverResp.Success.ShouldBeTrue(discoverResp.Error);
|
||||
discoverResp.Objects.Length.ShouldBeGreaterThan(0,
|
||||
"live Galaxy ZB has at least one deployed gobject");
|
||||
|
||||
await client.SendOneWayAsync(MessageKind.CloseSessionRequest,
|
||||
new CloseSessionRequest { SessionId = sessionResp.SessionId }, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the pipe name + shared secret the live <see cref="GalaxyProxyDriver"/> needs
|
||||
/// to connect to a running <c>OtOpcUaGalaxyHost</c> Windows service. Two sources are
|
||||
/// consulted, first match wins:
|
||||
/// <list type="number">
|
||||
/// <item>Explicit env vars (<c>OTOPCUA_GALAXY_PIPE</c>, <c>OTOPCUA_GALAXY_SECRET</c>) — lets CI / benchwork override.</item>
|
||||
/// <item>The service's per-process <c>Environment</c> registry values under
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c> — what
|
||||
/// <c>Install-Services.ps1</c> writes at install time. Requires the test to run as a
|
||||
/// principal with read access to that registry key (typically Administrators).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the
|
||||
/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret
|
||||
/// in tests would diverge from production the moment someone re-installed the service.
|
||||
/// </remarks>
|
||||
public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source)
|
||||
{
|
||||
public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE";
|
||||
public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET";
|
||||
public const string ServiceRegistryKey =
|
||||
@"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost";
|
||||
public const string DefaultPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static LiveStackConfig? Resolve()
|
||||
{
|
||||
var envPipe = Environment.GetEnvironmentVariable(EnvPipeName);
|
||||
var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret);
|
||||
if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret))
|
||||
return new LiveStackConfig(envPipe, envSecret, "env vars");
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return null;
|
||||
|
||||
return FromServiceRegistry();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static LiveStackConfig? FromServiceRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey);
|
||||
if (key is null) return null;
|
||||
var env = key.GetValue("Environment") as string[];
|
||||
if (env is null || env.Length == 0) return null;
|
||||
|
||||
string? pipe = null, secret = null;
|
||||
foreach (var line in env)
|
||||
{
|
||||
var eq = line.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
var name = line[..eq];
|
||||
var value = line[(eq + 1)..];
|
||||
if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value;
|
||||
else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret)) return null;
|
||||
return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Access denied / key missing / malformed — caller gets null and surfaces a Skip.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
|
||||
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
|
||||
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
|
||||
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
|
||||
/// to translate that into <c>Assert.Skip</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
|
||||
/// as a standalone Windows service — spawning a second instance from a test would
|
||||
/// bypass the COM-apartment + service-account setup and fail differently than
|
||||
/// production (see <c>project_galaxy_host_service.md</c> memory).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
|
||||
/// first, then the service's registry-stored <c>Environment</c> values. Requires
|
||||
/// the test process to have read access to
|
||||
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
|
||||
/// that typically means running the test host elevated, or exporting
|
||||
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LiveStackFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
public PrerequisiteReport? PrerequisiteReport { get; private set; }
|
||||
|
||||
public LiveStackConfig? Config { get; private set; }
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
|
||||
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
|
||||
cts.Token);
|
||||
|
||||
if (!PrerequisiteReport.IsLivetestReady)
|
||||
{
|
||||
SkipReason = PrerequisiteReport.SkipReason;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
|
||||
// env vars from registry (non-elevated test host), a clear message beats a silent
|
||||
// connect-rejected failure 10 seconds later.
|
||||
Config = LiveStackConfig.Resolve();
|
||||
if (Config is null)
|
||||
{
|
||||
SkipReason =
|
||||
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
|
||||
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
|
||||
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
|
||||
// ConnectTimeout gives enough headroom for a service that just started.
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "live-stack-smoke",
|
||||
PipeName = Config.PipeName,
|
||||
SharedSecret = Config.SharedSecret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason =
|
||||
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
|
||||
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
|
||||
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
|
||||
$"test must run as that user), or Host's backend couldn't connect to ZB.";
|
||||
Driver.Dispose();
|
||||
Driver = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
|
||||
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
|
||||
/// the full prerequisites report, not a cascading NullReferenceException on
|
||||
/// <see cref="Driver"/>.
|
||||
/// </summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null) Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
|
||||
{
|
||||
public const string Name = "LiveStack";
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
|
||||
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
|
||||
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
|
||||
/// live MXAccess runtime → real Galaxy objects + attributes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
|
||||
/// <c>Assert.Skip</c> when missing):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>AVEVA System Platform installed + Platform deployed.</item>
|
||||
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
|
||||
/// <item>MXAccess COM server registered.</item>
|
||||
/// <item>ZB database exists with at least one deployed gobject.</item>
|
||||
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
|
||||
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
|
||||
/// service's registry Environment values (test host typically needs to be elevated
|
||||
/// to read the latter).</item>
|
||||
/// <item>Test process runs as the account listed in the service's pipe ACL
|
||||
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
|
||||
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
|
||||
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
|
||||
/// PR that reuses this fixture.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveGalaxy")]
|
||||
[Collection(LiveStackCollection.Name)]
|
||||
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public void Fixture_initialized_successfully()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
|
||||
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
|
||||
// every other test in this class depends on it.
|
||||
fixture.Driver.ShouldNotBeNull();
|
||||
fixture.Config.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport.ShouldNotBeNull();
|
||||
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_reports_Healthy_after_IPC_handshake()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var health = fixture.Driver!.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy,
|
||||
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
|
||||
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
|
||||
|
||||
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
|
||||
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
|
||||
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHostStatuses_reports_at_least_one_platform()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var statuses = fixture.Driver!.GetHostStatuses();
|
||||
statuses.Count.ShouldBeGreaterThan(0,
|
||||
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
|
||||
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
|
||||
|
||||
// Host names are driver-opaque to the Core but non-empty by contract.
|
||||
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_read_a_discovered_variable_from_live_galaxy()
|
||||
{
|
||||
fixture.SkipIfUnavailable();
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
|
||||
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
|
||||
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
|
||||
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
|
||||
var full = builder.Variables[0].AttributeInfo.FullName;
|
||||
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
|
||||
|
||||
snapshots.Count.ShouldBe(1);
|
||||
var snap = snapshots[0];
|
||||
snap.StatusCode.ShouldNotBe(0x80020000u,
|
||||
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
|
||||
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
|
||||
{
|
||||
// PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
|
||||
// Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
|
||||
// with for exactly this kind of integration testing. We invert the current value and
|
||||
// assert the new value comes back, then restore the original so the test is effectively
|
||||
// idempotent (Galaxy holds the value across runs since it's a deployed UDA).
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Read current value first — gives the cleanup path the right baseline. Galaxy may
|
||||
// return Uncertain quality until the Engine has scanned the attribute at least once;
|
||||
// we don't read into a strongly-typed bool until Status is Good.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var inverted = !originalBool;
|
||||
|
||||
try
|
||||
{
|
||||
// Write the inverted value via IWritable.
|
||||
var writeResults = await fixture.Driver!.WriteAsync(
|
||||
[new(fullRef, inverted)], cts.Token);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u,
|
||||
$"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
|
||||
$"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");
|
||||
|
||||
// The Engine's scan + acknowledgement is async — read in a short loop with a 5s
|
||||
// budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
|
||||
// we give headroom for first-scan after a service restart.
|
||||
DataValueSnapshot after = default!;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
|
||||
await Task.Delay(200, cts.Token);
|
||||
}
|
||||
after.StatusCode.ShouldBe(0u, "post-write read failed");
|
||||
Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
|
||||
$"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore — best-effort. If this throws the test still reports its primary result;
|
||||
// we just leave a flipped TestAttribute on the dev box (benign, name says it all).
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
|
||||
{
|
||||
// Subscribe + write is the canonical "is the data path actually live" test for
|
||||
// an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
|
||||
// value callback within a couple of seconds (per ISubscribable's contract — the
|
||||
// driver MAY fire OnDataChange immediately with the current value), then write a
|
||||
// distinct value and expect a second callback carrying the new value.
|
||||
fixture.SkipIfUnavailable();
|
||||
const string fullRef = "DelmiaReceiver_001.TestAttribute";
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
// Capture every OnDataChange notification for this fullRef onto a thread-safe queue
|
||||
// we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
|
||||
// thread; we don't want to block it.
|
||||
var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
void Handler(object? sender, DataChangeEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
|
||||
notifications.Enqueue(e.Snapshot);
|
||||
}
|
||||
fixture.Driver!.OnDataChange += Handler;
|
||||
|
||||
// Read current value so we know which value to write to force a transition.
|
||||
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
|
||||
var originalBool = Convert.ToBoolean(before.Value ?? false);
|
||||
var toWrite = !originalBool;
|
||||
|
||||
ISubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = await fixture.Driver!.SubscribeAsync(
|
||||
[fullRef], TimeSpan.FromMilliseconds(250), cts.Token);
|
||||
|
||||
// Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
|
||||
await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
|
||||
$"No initial-value OnDataChange for {fullRef} within 5s. " +
|
||||
$"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");
|
||||
|
||||
// Drain the initial-value queue before writing so we count post-write deltas only.
|
||||
var initialCount = notifications.Count;
|
||||
|
||||
// Write the toggled value. Engine scan + advisory fires the second callback.
|
||||
var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
|
||||
notifications.Count.ShouldBeGreaterThan(initialCount,
|
||||
$"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");
|
||||
|
||||
// Find the post-write notification carrying the toggled value (initial value may
|
||||
// appear multiple times before the write commits — search the tail).
|
||||
var postWrite = notifications.ToArray().Reverse()
|
||||
.FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
|
||||
postWrite.ShouldNotBe(default,
|
||||
$"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
|
||||
string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.Driver!.OnDataChange -= Handler;
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
// Restore baseline.
|
||||
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + budget;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
|
||||
/// Variable() call into a flat list so tests can inspect what discovery produced
|
||||
/// without running the full OPC UA node-manager stack.
|
||||
/// </summary>
|
||||
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add((browseName, attributeInfo));
|
||||
return new NoopHandle(attributeInfo.FullName);
|
||||
}
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class NoopHandle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference { get; } = fullReference;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
|
||||
private sealed class NoopSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,68 +0,0 @@
|
||||
using System.Reflection;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ContractRoundTripTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Every MessagePack contract in the Shared project must round-trip. Byte-for-byte equality
|
||||
/// on re-serialization proves the contract is deterministic — critical for the Hello
|
||||
/// version-negotiation hash and for debugging wire dumps.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void All_MessagePackObject_contracts_round_trip_byte_for_byte()
|
||||
{
|
||||
var contractTypes = typeof(Hello).Assembly.GetTypes()
|
||||
.Where(t => t.GetCustomAttribute<MessagePackObjectAttribute>() is not null)
|
||||
.ToList();
|
||||
|
||||
contractTypes.Count.ShouldBeGreaterThan(15, "scan should find all contracts");
|
||||
|
||||
foreach (var type in contractTypes)
|
||||
{
|
||||
var instance = Activator.CreateInstance(type);
|
||||
var bytes1 = MessagePackSerializer.Serialize(type, instance);
|
||||
var hydrated = MessagePackSerializer.Deserialize(type, bytes1);
|
||||
var bytes2 = MessagePackSerializer.Serialize(type, hydrated);
|
||||
|
||||
bytes2.ShouldBe(bytes1, $"{type.Name} did not round-trip byte-for-byte");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hello_default_reports_current_protocol_version()
|
||||
{
|
||||
var h = new Hello { PeerName = "Proxy", SharedSecret = "x" };
|
||||
h.ProtocolMajor.ShouldBe(Hello.CurrentMajor);
|
||||
h.ProtocolMinor.ShouldBe(Hello.CurrentMinor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSessionRequest_round_trips_values()
|
||||
{
|
||||
var req = new OpenSessionRequest { DriverInstanceId = "gal-1", DriverConfigJson = "{\"x\":1}" };
|
||||
var bytes = MessagePackSerializer.Serialize(req);
|
||||
var hydrated = MessagePackSerializer.Deserialize<OpenSessionRequest>(bytes);
|
||||
|
||||
hydrated.DriverInstanceId.ShouldBe("gal-1");
|
||||
hydrated.DriverConfigJson.ShouldBe("{\"x\":1}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contracts_reference_only_BCL_and_MessagePack()
|
||||
{
|
||||
var asm = typeof(Hello).Assembly;
|
||||
var references = asm.GetReferencedAssemblies()
|
||||
.Select(n => n.Name!)
|
||||
.Where(n => !n.StartsWith("System.") && n != "mscorlib" && n != "netstandard")
|
||||
.ToList();
|
||||
|
||||
// Only MessagePack should appear outside BCL — no System.Text.Json, no EF, no AspNetCore.
|
||||
references.ShouldAllBe(n => n == "MessagePack" || n == "MessagePack.Annotations");
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FramingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FrameWriter_FrameReader_round_trip_preserves_kind_and_body()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
using (var writer = new FrameWriter(ms, leaveOpen: true))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { PeerName = "p", SharedSecret = "s" }, TestContext.Current.CancellationToken);
|
||||
await writer.WriteAsync(MessageKind.Heartbeat,
|
||||
new Heartbeat { SequenceNumber = 7, UtcUnixMs = 42 }, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
using var reader = new FrameReader(ms, leaveOpen: true);
|
||||
|
||||
var f1 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value;
|
||||
f1.Kind.ShouldBe(MessageKind.Hello);
|
||||
FrameReader.Deserialize<Hello>(f1.Body).PeerName.ShouldBe("p");
|
||||
|
||||
var f2 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value;
|
||||
f2.Kind.ShouldBe(MessageKind.Heartbeat);
|
||||
FrameReader.Deserialize<Heartbeat>(f2.Body).SequenceNumber.ShouldBe(7L);
|
||||
|
||||
var eof = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
|
||||
eof.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrameReader_rejects_frames_larger_than_the_cap()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
var evilLen = Framing.MaxFrameBodyBytes + 1;
|
||||
ms.Write(new byte[]
|
||||
{
|
||||
(byte)((evilLen >> 24) & 0xFF),
|
||||
(byte)((evilLen >> 16) & 0xFF),
|
||||
(byte)((evilLen >> 8) & 0xFF),
|
||||
(byte)( evilLen & 0xFF),
|
||||
}, 0, 4);
|
||||
ms.WriteByte((byte)MessageKind.Hello);
|
||||
ms.Position = 0;
|
||||
|
||||
using var reader = new FrameReader(ms, leaveOpen: true);
|
||||
await Should.ThrowAsync<InvalidDataException>(() =>
|
||||
reader.ReadFrameAsync(TestContext.Current.CancellationToken).AsTask());
|
||||
}
|
||||
|
||||
private static class TestContext
|
||||
{
|
||||
public static TestContextHelper Current { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class TestContextHelper
|
||||
{
|
||||
public CancellationToken CancellationToken => CancellationToken.None;
|
||||
}
|
||||
}
|
||||
|
||||
file static class TaskExtensions
|
||||
{
|
||||
public static Task AsTask<T>(this ValueTask<T> vt) => vt.AsTask();
|
||||
public static Task AsTask<T>(this Task<T> t) => t;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,163 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for live-AVEVA test fixtures. Runs every relevant probe and returns a
|
||||
/// <see cref="PrerequisiteReport"/> whose <c>SkipReason</c> feeds <c>Assert.Skip</c> when
|
||||
/// the environment isn't set up. Non-Windows hosts get a single aggregated Skip row per
|
||||
/// category instead of a flood of individual skips.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Call shape</b>:</para>
|
||||
/// <code>
|
||||
/// var report = await AvevaPrerequisites.CheckAllAsync();
|
||||
/// if (report.SkipReason is not null) Assert.Skip(report.SkipReason);
|
||||
/// </code>
|
||||
/// <para><b>Categories in rough order of 'would I want to know first?'</b>:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Environment — process bitness, OS platform, RPCSS up.</item>
|
||||
/// <item>AvevaInstall — Framework registry, install paths, no pending reboot.</item>
|
||||
/// <item>AvevaCoreService — aaBootstrap / aaGR / NmxSvc running.</item>
|
||||
/// <item>MxAccessCom — LMXProxy.LMXProxyServer ProgID → CLSID → file-on-disk.</item>
|
||||
/// <item>GalaxyRepository — SQL reachable, ZB exists, deployed-object count.</item>
|
||||
/// <item>OtOpcUaService — our two Windows services + GLAuth.</item>
|
||||
/// <item>AvevaSoftService — aaLogger etc., warn only.</item>
|
||||
/// <item>AvevaHistorian — aahClientAccessPoint etc., optional.</item>
|
||||
/// </list>
|
||||
/// <para><b>What's NOT checked here</b>: end-to-end subscribe / read / write against a real
|
||||
/// Galaxy tag. That's the job of the live-smoke tests this helper gates — the helper just
|
||||
/// tells them whether running is worthwhile.</para>
|
||||
/// </remarks>
|
||||
public static class AvevaPrerequisites
|
||||
{
|
||||
// -------- Individual service lists (kept as data so tests can inspect / override) --------
|
||||
|
||||
/// <summary>Services whose absence means live-Galaxy tests can't run at all.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] CoreServices =
|
||||
[
|
||||
("aaBootstrap", "master service that starts the Platform process + brokers aa* communication"),
|
||||
("aaGR", "Galaxy Repository host — mediates IDE / runtime access to ZB"),
|
||||
("NmxSvc", "Network Message Exchange — MXAccess + Bootstrap transport"),
|
||||
("MSSQLSERVER", "SQL Server instance that hosts the ZB database"),
|
||||
];
|
||||
|
||||
/// <summary>Warn-but-don't-fail AVEVA services.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] SoftServices =
|
||||
[
|
||||
("aaLogger", "ArchestrA Logger — diagnostic log receiver; stack runs without it but error visibility suffers"),
|
||||
("aaUserValidator", "OS user/group auth for ArchestrA security; only required when Galaxy security mode isn't 'Open'"),
|
||||
("aaGlobalDataCacheMonitorSvr", "cross-platform global data cache; single-node dev boxes run fine without it"),
|
||||
];
|
||||
|
||||
/// <summary>Optional AVEVA Historian services — only required for HistoryRead IPC paths.</summary>
|
||||
internal static readonly (string Name, string Purpose)[] HistorianServices =
|
||||
[
|
||||
("aahClientAccessPoint", "AVEVA Historian Client Access Point — HistoryRead IPC endpoint"),
|
||||
("aahGateway", "AVEVA Historian Gateway"),
|
||||
];
|
||||
|
||||
/// <summary>OtOpcUa-stack Windows services + third-party deps we manage.</summary>
|
||||
internal static readonly (string Name, string Purpose, bool HardRequired)[] OtOpcUaServices =
|
||||
[
|
||||
("OtOpcUaGalaxyHost", "Galaxy.Host out-of-process service (net48 x86, STA + MXAccess)", true),
|
||||
("OtOpcUa", "Main OPC UA server service (hosts Proxy + DriverHost + Admin-facing DB publisher)", false),
|
||||
("GLAuth", "LDAP server (dev only) — glauth.exe on localhost:3893", false),
|
||||
];
|
||||
|
||||
// -------- Orchestrator --------
|
||||
|
||||
public static async Task<PrerequisiteReport> CheckAllAsync(
|
||||
Options? options = null, CancellationToken ct = default)
|
||||
{
|
||||
options ??= new Options();
|
||||
var checks = new List<PrerequisiteCheck>();
|
||||
|
||||
// Environment
|
||||
checks.Add(MxAccessComProbe.CheckProcessBitness());
|
||||
|
||||
// AvevaInstall — registry + files
|
||||
checks.Add(RegistryProbe.CheckFrameworkInstalled());
|
||||
checks.Add(RegistryProbe.CheckPlatformDeployed());
|
||||
checks.Add(RegistryProbe.CheckRebootPending());
|
||||
|
||||
// AvevaCoreService
|
||||
foreach (var (name, purpose) in CoreServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaCoreService, hardRequired: true, whatItDoes: purpose));
|
||||
|
||||
// MxAccessCom
|
||||
checks.Add(MxAccessComProbe.Check());
|
||||
|
||||
// GalaxyRepository
|
||||
checks.Add(await SqlProbe.CheckZbDatabaseAsync(options.SqlConnectionString, ct));
|
||||
// Deployed-object count only makes sense if the DB check passed.
|
||||
if (checks[checks.Count - 1].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(options.SqlConnectionString, ct));
|
||||
|
||||
// OtOpcUaService
|
||||
foreach (var (name, purpose, hard) in OtOpcUaServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.OtOpcUaService, hardRequired: hard, whatItDoes: purpose));
|
||||
if (options.CheckGalaxyHostPipe)
|
||||
checks.Add(await NamedPipeProbe.CheckGalaxyHostPipeAsync(options.GalaxyHostPipeName, ct));
|
||||
|
||||
// AvevaSoftService
|
||||
foreach (var (name, purpose) in SoftServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaSoftService, hardRequired: false, whatItDoes: purpose));
|
||||
|
||||
// AvevaHistorian
|
||||
if (options.CheckHistorian)
|
||||
{
|
||||
foreach (var (name, purpose) in HistorianServices)
|
||||
checks.Add(ServiceProbe.Check(name, PrerequisiteCategory.AvevaHistorian, hardRequired: false, whatItDoes: purpose));
|
||||
}
|
||||
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for tests that only need the Galaxy Repository (SQL) path — don't
|
||||
/// pay the cost of probing every aa* service when the test only reads gobject rows.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckRepositoryOnlyAsync(
|
||||
string? sqlConnectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await SqlProbe.CheckZbDatabaseAsync(sqlConnectionString, ct),
|
||||
};
|
||||
if (checks[0].Status == PrerequisiteStatus.Pass)
|
||||
checks.Add(await SqlProbe.CheckDeployedObjectCountAsync(sqlConnectionString, ct));
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower check for the named-pipe endpoint — tests that drive the full Proxy
|
||||
/// against a live Galaxy.Host service don't need the SQL or AVEVA-internal probes
|
||||
/// (the Host does that work internally; we just need the pipe to accept).
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteReport> CheckGalaxyHostPipeOnlyAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
var checks = new List<PrerequisiteCheck>
|
||||
{
|
||||
await NamedPipeProbe.CheckGalaxyHostPipeAsync(pipeName, ct),
|
||||
};
|
||||
return new PrerequisiteReport(checks);
|
||||
}
|
||||
|
||||
/// <summary>Knobs for <see cref="CheckAllAsync"/>.</summary>
|
||||
public sealed class Options
|
||||
{
|
||||
/// <summary>SQL Server connection string — defaults to Windows-auth <c>localhost\ZB</c>.</summary>
|
||||
public string? SqlConnectionString { get; init; }
|
||||
|
||||
/// <summary>Named-pipe endpoint for OtOpcUaGalaxyHost — defaults to <c>OtOpcUaGalaxy</c>.</summary>
|
||||
public string? GalaxyHostPipeName { get; init; }
|
||||
|
||||
/// <summary>Include the named-pipe probe. Off by default — it's a seconds-long TCP-like probe and some tests don't need it.</summary>
|
||||
public bool CheckGalaxyHostPipe { get; init; } = true;
|
||||
|
||||
/// <summary>Include Historian service probes. Off by default — Historian is optional.</summary>
|
||||
public bool CheckHistorian { get; init; } = false;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#if NET48
|
||||
// Polyfills for C# 9+ language features that the helper uses but that net48 BCL doesn't
|
||||
// provide. Keeps the sources single-target-free at the language level — the same .cs files
|
||||
// build on both frameworks without preprocessor guards in the callsites.
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
/// <summary>Required by C# 9 <c>init</c>-only setters and <c>record</c> types.</summary>
|
||||
internal static class IsExternalInit { }
|
||||
}
|
||||
|
||||
namespace System.Runtime.Versioning
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal shim for the .NET 5+ <c>SupportedOSPlatformAttribute</c>. Pure marker for the
|
||||
/// compiler on net10; on net48 we still want the attribute to exist so the same
|
||||
/// <c>[SupportedOSPlatform("windows")]</c> source compiles. The attribute is internal
|
||||
/// and attribute-targets-everything to minimize surface.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
|
||||
internal sealed class SupportedOSPlatformAttribute(string platformName) : Attribute
|
||||
{
|
||||
public string PlatformName { get; } = platformName;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,44 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>One prerequisite probe's outcome. <see cref="AvevaPrerequisites"/> returns many of these.</summary>
|
||||
/// <param name="Name">Short diagnostic id — e.g. <c>service:aaBootstrap</c>, <c>sql:ZB</c>, <c>registry:ArchestrA.Framework</c>.</param>
|
||||
/// <param name="Category">Which subsystem the probe belongs to — lets callers filter (e.g. "Historian warns don't gate the core Galaxy smoke").</param>
|
||||
/// <param name="Status">Outcome.</param>
|
||||
/// <param name="Detail">One-line specific message an operator can act on — <c>"aaGR not installed — install the Galaxy Repository role from the System Platform setup"</c> beats <c>"failed"</c>.</param>
|
||||
public sealed record PrerequisiteCheck(
|
||||
string Name,
|
||||
PrerequisiteCategory Category,
|
||||
PrerequisiteStatus Status,
|
||||
string Detail);
|
||||
|
||||
public enum PrerequisiteStatus
|
||||
{
|
||||
/// <summary>Prerequisite is met; no action needed.</summary>
|
||||
Pass,
|
||||
/// <summary>Soft dependency missing — stack still runs but some feature (e.g. logging) is degraded.</summary>
|
||||
Warn,
|
||||
/// <summary>Hard dependency missing — live tests can't proceed; <see cref="PrerequisiteReport.SkipReason"/> surfaces this.</summary>
|
||||
Fail,
|
||||
/// <summary>Probe wasn't applicable in this environment (e.g. non-Windows host, Historian not installed).</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
public enum PrerequisiteCategory
|
||||
{
|
||||
/// <summary>Platform sanity — process bitness, OS platform, DCOM/RPCSS.</summary>
|
||||
Environment,
|
||||
/// <summary>Hard-required AVEVA Windows services (aaBootstrap, aaGR, NmxSvc).</summary>
|
||||
AvevaCoreService,
|
||||
/// <summary>Soft-required AVEVA Windows services (aaLogger, aaUserValidator) — warn only.</summary>
|
||||
AvevaSoftService,
|
||||
/// <summary>ArchestrA Framework install markers (registry + files).</summary>
|
||||
AvevaInstall,
|
||||
/// <summary>MXAccess COM server registration + file on disk.</summary>
|
||||
MxAccessCom,
|
||||
/// <summary>SQL Server reachability + ZB database presence + deployed-object count.</summary>
|
||||
GalaxyRepository,
|
||||
/// <summary>Historian services (optional — only required for HistoryRead IPC paths).</summary>
|
||||
AvevaHistorian,
|
||||
/// <summary>OtOpcUa-side services (OtOpcUa, OtOpcUaGalaxyHost) + third-party deps (GLAuth).</summary>
|
||||
OtOpcUaService,
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated result of an <see cref="AvevaPrerequisites.CheckAll"/> run. Test fixtures
|
||||
/// typically call <see cref="SkipReason"/> to produce the argument for xUnit's
|
||||
/// <c>Assert.Skip</c> when any hard dependency failed.
|
||||
/// </summary>
|
||||
public sealed class PrerequisiteReport
|
||||
{
|
||||
public IReadOnlyList<PrerequisiteCheck> Checks { get; }
|
||||
|
||||
public PrerequisiteReport(IEnumerable<PrerequisiteCheck> checks)
|
||||
{
|
||||
Checks = [.. checks];
|
||||
}
|
||||
|
||||
/// <summary>True when every probe is Pass / Warn / Skip — no Fail entries.</summary>
|
||||
public bool IsLivetestReady => !Checks.Any(c => c.Status == PrerequisiteStatus.Fail);
|
||||
|
||||
/// <summary>
|
||||
/// True when only the AVEVA-side probes pass — ignores failures in the
|
||||
/// <see cref="PrerequisiteCategory.OtOpcUaService"/> category. Lets a live-test gate
|
||||
/// say "AVEVA is ready even if the v2 services aren't installed yet" without
|
||||
/// conflating the two. Useful for tests that exercise Galaxy directly (e.g.
|
||||
/// <see cref="GalaxyRepositoryLiveSmokeTests"/>) rather than through our stack.
|
||||
/// </summary>
|
||||
public bool IsAvevaSideReady =>
|
||||
!Checks.Any(c => c.Status == PrerequisiteStatus.Fail && c.Category != PrerequisiteCategory.OtOpcUaService);
|
||||
|
||||
/// <summary>
|
||||
/// Multi-line message for <c>Assert.Skip</c> when a hard dependency isn't met. Returns
|
||||
/// null when <see cref="IsLivetestReady"/> is true.
|
||||
/// </summary>
|
||||
public string? SkipReason
|
||||
{
|
||||
get
|
||||
{
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail).ToList();
|
||||
if (fails.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Live-AVEVA prerequisites not met ({fails.Count} failed):");
|
||||
foreach (var f in fails)
|
||||
sb.AppendLine($" • [{f.Category}] {f.Name} — {f.Detail}");
|
||||
sb.Append("Run `Get-Service aa*` / `sqlcmd -S localhost -d ZB -E -Q \"SELECT 1\"` to triage.");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of warnings — caller decides whether to log or ignore. Useful
|
||||
/// when a live test does pass but an operator should know their environment is degraded.
|
||||
/// </summary>
|
||||
public string? Warnings
|
||||
{
|
||||
get
|
||||
{
|
||||
var warns = Checks.Where(c => c.Status == PrerequisiteStatus.Warn).ToList();
|
||||
if (warns.Count == 0) return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"AVEVA prerequisites with warnings ({warns.Count}):");
|
||||
foreach (var w in warns)
|
||||
sb.AppendLine($" • [{w.Category}] {w.Name} — {w.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="InvalidOperationException"/> if any <paramref name="categories"/>
|
||||
/// contain a Fail — useful when a specific test needs, say, Galaxy Repository but doesn't
|
||||
/// care about Historian. Call before <c>Assert.Skip</c> if you want to be strict.
|
||||
/// </summary>
|
||||
public void RequireCategories(params PrerequisiteCategory[] categories)
|
||||
{
|
||||
var set = categories.ToHashSet();
|
||||
var fails = Checks.Where(c => c.Status == PrerequisiteStatus.Fail && set.Contains(c.Category)).ToList();
|
||||
if (fails.Count == 0) return;
|
||||
|
||||
var detail = string.Join("; ", fails.Select(f => $"{f.Name}: {f.Detail}"));
|
||||
throw new InvalidOperationException($"Required prerequisite categories failed: {detail}");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"PrerequisiteReport: {Checks.Count} checks");
|
||||
foreach (var c in Checks)
|
||||
sb.AppendLine($" [{c.Status,-4}] {c.Category}/{c.Name}: {c.Detail}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Confirms MXAccess COM server registration by resolving the
|
||||
/// <c>LMXProxy.LMXProxyServer</c> ProgID to its CLSID, then checking that the CLSID's
|
||||
/// 32-bit <c>InprocServer32</c> entry points at a file that exists on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A common failure mode on partial installs: ProgID is registered but the CLSID
|
||||
/// InprocServer32 DLL is missing (previous install uninstalled but registry orphan remains).
|
||||
/// This probe surfaces that case with an actionable message instead of the
|
||||
/// <c>0x80040154 REGDB_E_CLASSNOTREG</c> you'd see from a late COM activation failure.
|
||||
/// </remarks>
|
||||
public static class MxAccessComProbe
|
||||
{
|
||||
public const string ProgId = "LMXProxy.LMXProxyServer";
|
||||
public const string VersionedProgId = "LMXProxy.LMXProxyServer.1";
|
||||
|
||||
public static PrerequisiteCheck Check()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Skip, "COM registration probes only run on Windows.");
|
||||
}
|
||||
return CheckWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (clsid, dll) = RegistryProbe.ResolveProgIdToInproc(ProgId);
|
||||
if (clsid is null)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} not registered — MXAccess COM server isn't installed. " +
|
||||
$"Install System Platform's MXAccess component and re-run.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} but InprocServer32 is empty. " +
|
||||
$"Registry is orphaned; re-register with: regsvr32 /s LmxProxy.dll (from an elevated cmd in the Framework bin dir).");
|
||||
}
|
||||
|
||||
// Resolve the recorded path — sometimes registered as a bare filename that the COM
|
||||
// runtime resolves via the current process's DLL-search path. Accept either an
|
||||
// absolute path that exists, or a bare filename whose resolution we can't verify
|
||||
// without loading it (treat as Pass-with-note).
|
||||
if (Path.IsPathRooted(dll))
|
||||
{
|
||||
if (!File.Exists(dll))
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"ProgID {ProgId} → CLSID {clsid} → InprocServer32 {dll}, but the file is missing. " +
|
||||
$"Re-install the Framework or restore from backup.");
|
||||
}
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (file exists).");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"ProgID {ProgId} → {dll} (bare filename — relies on PATH resolution at COM activation time).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("com:LMXProxy", PrerequisiteCategory.MxAccessCom,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn when running as a 64-bit process — MXAccess COM activation will fail with
|
||||
/// <c>0x80040154</c> regardless of registration state. The production drivers run net48
|
||||
/// x86; xunit hosts run 64-bit by default so this often surfaces first.
|
||||
/// </summary>
|
||||
public static PrerequisiteCheck CheckProcessBitness()
|
||||
{
|
||||
if (Environment.Is64BitProcess)
|
||||
{
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Warn,
|
||||
"Test host is 64-bit. Direct MXAccess COM activation would fail with REGDB_E_CLASSNOTREG (0x80040154); " +
|
||||
"the production driver workaround is to run Galaxy.Host as a 32-bit process. Tests that only " +
|
||||
"talk to the Host service over the named pipe aren't affected.");
|
||||
}
|
||||
return new PrerequisiteCheck("env:ProcessBitness", PrerequisiteCategory.Environment,
|
||||
PrerequisiteStatus.Pass, "Test host is 32-bit.");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <c>OtOpcUaGalaxyHost</c> named-pipe endpoint is accepting connections —
|
||||
/// the handshake the Proxy performs at boot. A clean pipe connect without sending any
|
||||
/// framed message proves the Host service is listening; we disconnect immediately so we
|
||||
/// don't consume a session slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default pipe name matches the installer script's <c>OTOPCUA_GALAXY_PIPE</c> default.
|
||||
/// Override when the Host service was installed with a non-default name (custom deployments).
|
||||
/// </remarks>
|
||||
public static class NamedPipeProbe
|
||||
{
|
||||
public const string DefaultGalaxyHostPipeName = "OtOpcUaGalaxy";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckGalaxyHostPipeAsync(
|
||||
string? pipeName = null, CancellationToken ct = default)
|
||||
{
|
||||
pipeName ??= DefaultGalaxyHostPipeName;
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
await client.ConnectAsync(cts.Token);
|
||||
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Pass,
|
||||
$@"Pipe \\.\pipe\{pipeName} accepted a connection — OtOpcUaGalaxyHost is listening.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} not connectable within 2s — OtOpcUaGalaxyHost service isn't running. " +
|
||||
"Start with: sc.exe start OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect timed out — service may be starting or stuck. " +
|
||||
"Check: sc.exe query OtOpcUaGalaxyHost");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("pipe:OtOpcUaGalaxyHost", PrerequisiteCategory.OtOpcUaService,
|
||||
PrerequisiteStatus.Fail,
|
||||
$@"Pipe \\.\pipe\{pipeName} connect failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Reads HKLM registry keys to confirm ArchestrA Framework / System Platform install
|
||||
/// markers. Matches the registered paths documented in
|
||||
/// <c>docs/v2/implementation/</c> — System Platform is 32-bit so keys live under
|
||||
/// <c>HKLM\SOFTWARE\WOW6432Node\ArchestrA\...</c>.
|
||||
/// </summary>
|
||||
public static class RegistryProbe
|
||||
{
|
||||
// Canonical install roots per the research on our dev box (System Platform 2020 R2).
|
||||
public const string ArchestrARootKey = @"SOFTWARE\WOW6432Node\ArchestrA";
|
||||
public const string FrameworkKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework";
|
||||
public const string PlatformKey = @"SOFTWARE\WOW6432Node\ArchestrA\Framework\Platform";
|
||||
public const string MsiInstallKey = @"SOFTWARE\WOW6432Node\ArchestrA\MSIInstall";
|
||||
|
||||
public static PrerequisiteCheck CheckFrameworkInstalled()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return FrameworkInstalledWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckPlatformDeployed()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return PlatformDeployedWindows();
|
||||
}
|
||||
|
||||
public static PrerequisiteCheck CheckRebootPending()
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Skip, "Registry probes only run on Windows.");
|
||||
}
|
||||
return RebootPendingWindows();
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck FrameworkInstalledWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(FrameworkKey);
|
||||
if (key is null)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Missing {FrameworkKey} — ArchestrA Framework isn't installed. Install AVEVA System Platform from the setup media.");
|
||||
}
|
||||
|
||||
var installPath = key.GetValue("InstallPath") as string;
|
||||
var rootPath = key.GetValue("RootPath") as string;
|
||||
if (string.IsNullOrWhiteSpace(installPath) || string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Framework key exists but InstallPath/RootPath values missing — install may be incomplete.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Installed at {installPath} (RootPath {rootPath}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Framework", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck PlatformDeployedWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(PlatformKey);
|
||||
var pfeConfig = key?.GetValue("PfeConfigOptions") as string;
|
||||
if (string.IsNullOrWhiteSpace(pfeConfig))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"No Platform object deployed locally (Platform\\PfeConfigOptions empty). MXAccess will connect but subscriptions will fail. Deploy a Platform from the IDE.");
|
||||
}
|
||||
|
||||
// PfeConfigOptions format: "PlatformId=N,EngineId=N,EngineName=...,..."
|
||||
// A non-deployed state leaves PlatformId=0 or the key empty.
|
||||
if (pfeConfig.Contains("PlatformId=0,", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Platform never deployed (PfeConfigOptions has PlatformId=0). Deploy a Platform from the IDE before running live tests.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
$"Platform deployed ({pfeConfig}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.Platform.Deployed", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck RebootPendingWindows()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(MsiInstallKey);
|
||||
var rebootRequired = key?.GetValue("RebootRequired") as string;
|
||||
if (string.Equals(rebootRequired, "True", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
"An ArchestrA patch has been installed but the machine hasn't rebooted. Post-patch behavior is undefined until a reboot.");
|
||||
}
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Pass,
|
||||
"No pending reboot flagged.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("registry:ArchestrA.RebootPending", PrerequisiteCategory.AvevaInstall,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Probe failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the registered <see cref="ComProgIdCheck"/> CLSID for the given ProgID and
|
||||
/// resolve the 32-bit <c>InprocServer32</c> file path. Returns null when either is missing.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static (string? Clsid, string? InprocDllPath) ResolveProgIdToInproc(string progId)
|
||||
{
|
||||
using var progIdKey = Registry.ClassesRoot.OpenSubKey($@"{progId}\CLSID");
|
||||
var clsid = progIdKey?.GetValue(null) as string;
|
||||
if (string.IsNullOrWhiteSpace(clsid)) return (null, null);
|
||||
|
||||
// 32-bit COM server under Wow6432Node\CLSID\{guid}\InprocServer32 default value.
|
||||
using var inproc = Registry.LocalMachine.OpenSubKey(
|
||||
$@"SOFTWARE\Classes\WOW6432Node\CLSID\{clsid}\InprocServer32");
|
||||
var dll = inproc?.GetValue(null) as string;
|
||||
return (clsid, dll);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceProcess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Queries the Windows Service Control Manager to report whether a named service is
|
||||
/// installed, its current state, and its start type. Non-Windows hosts return Skip.
|
||||
/// </summary>
|
||||
public static class ServiceProbe
|
||||
{
|
||||
public static PrerequisiteCheck Check(
|
||||
string serviceName,
|
||||
PrerequisiteCategory category,
|
||||
bool hardRequired,
|
||||
string whatItDoes)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
Name: $"service:{serviceName}",
|
||||
Category: category,
|
||||
Status: PrerequisiteStatus.Skip,
|
||||
Detail: "Service probes only run on Windows.");
|
||||
}
|
||||
|
||||
return CheckWindows(serviceName, category, hardRequired, whatItDoes);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static PrerequisiteCheck CheckWindows(
|
||||
string serviceName, PrerequisiteCategory category, bool hardRequired, string whatItDoes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var sc = new ServiceController(serviceName);
|
||||
// Touch the Status to force the SCM lookup; if the service doesn't exist, this throws
|
||||
// InvalidOperationException with message "Service ... was not found on computer.".
|
||||
var status = sc.Status;
|
||||
var startType = sc.StartType;
|
||||
|
||||
return status switch
|
||||
{
|
||||
ServiceControllerStatus.Running => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Pass,
|
||||
$"Running ({whatItDoes})"),
|
||||
|
||||
// DemandStart services (like NmxSvc) that are Stopped are not necessarily a
|
||||
// failure — the master service (aaBootstrap) brings them up on demand. Treat
|
||||
// Stopped+Demand as Warn so operators know the situation but tests still proceed.
|
||||
ServiceControllerStatus.Stopped when startType == ServiceStartMode.Manual =>
|
||||
new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Installed but Stopped (start type Manual — {whatItDoes}). " +
|
||||
"Will be pulled up on demand by the master service; fine for tests."),
|
||||
|
||||
ServiceControllerStatus.Stopped => Fail(
|
||||
$"Installed but Stopped. Start with: sc.exe start {serviceName} ({whatItDoes})"),
|
||||
|
||||
_ => new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Transitional state {status} ({whatItDoes}) — try again in a few seconds."),
|
||||
};
|
||||
|
||||
PrerequisiteCheck Fail(string detail) => new(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
detail);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category,
|
||||
hardRequired ? PrerequisiteStatus.Fail : PrerequisiteStatus.Warn,
|
||||
$"Not installed ({whatItDoes}). Install the relevant System Platform component and retry.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck(
|
||||
$"service:{serviceName}", category, PrerequisiteStatus.Warn,
|
||||
$"Probe failed ({ex.GetType().Name}: {ex.Message}) — treat as unknown.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.Probes;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Galaxy Repository SQL side: SQL Server reachable, <c>ZB</c> database
|
||||
/// present, and at least one deployed object exists (so live tests have something to read).
|
||||
/// Reuses the Windows-auth connection string the repo code defaults to.
|
||||
/// </summary>
|
||||
public static class SqlProbe
|
||||
{
|
||||
public const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;Connect Timeout=3;";
|
||||
|
||||
public static async Task<PrerequisiteCheck> CheckZbDatabaseAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
// DB_ID returns null when the database doesn't exist on the connected server — distinct
|
||||
// failure mode from "server unreachable", deserves a distinct message.
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DB_ID('ZB')";
|
||||
var dbIdObj = await cmd.ExecuteScalarAsync(ct);
|
||||
if (dbIdObj is null || dbIdObj is DBNull)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
"SQL Server reachable but database ZB does not exist. " +
|
||||
"Create the Galaxy from the IDE or restore a .cab backup.");
|
||||
}
|
||||
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, "Connected; ZB database exists.");
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"SQL Server unreachable: {ex.Message}. Ensure MSSQLSERVER service is running (sc.exe start MSSQLSERVER) and TCP 1433 is open.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Fail,
|
||||
$"Unexpected probe error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of deployed Galaxy objects (<c>deployed_version > 0</c>). Zero
|
||||
/// isn't a hard failure — lets someone boot a fresh Galaxy and still get meaningful
|
||||
/// test-suite output — but it IS a warning because any live-read smoke will have
|
||||
/// nothing to read.
|
||||
/// </summary>
|
||||
public static async Task<PrerequisiteCheck> CheckDeployedObjectCountAsync(
|
||||
string? connectionString = null, CancellationToken ct = default)
|
||||
{
|
||||
connectionString ??= DefaultConnectionString;
|
||||
try
|
||||
{
|
||||
using var conn = new SqlConnection(connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM gobject WHERE deployed_version > 0";
|
||||
var countObj = await cmd.ExecuteScalarAsync(ct);
|
||||
var count = countObj is int i ? i : 0;
|
||||
|
||||
return count > 0
|
||||
? new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Pass, $"{count} objects deployed — live reads have data to return.")
|
||||
: new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
"ZB contains no deployed objects. Discovery smoke tests will return empty hierarchies; " +
|
||||
"deploy at least a Platform + AppEngine from the IDE to exercise the read path.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrerequisiteCheck("sql:ZB.deployedObjects", PrerequisiteCategory.GalaxyRepository,
|
||||
PrerequisiteStatus.Warn,
|
||||
$"Couldn't count deployed objects: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Multi-target: net10.0 for modern consumer projects (Galaxy.Proxy.Tests, E2E, Admin.Tests),
|
||||
net48 for the Galaxy.Host.Tests project that has to stay on .NET Framework x86 for its
|
||||
MXAccess-COM parent project. The helper uses no OS-level APIs that differ between the
|
||||
two frameworks (registry / SQL / ServiceController are surface-compatible). -->
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<!-- System.ServiceProcess.ServiceController + Microsoft.Win32.Registry are cross-platform
|
||||
assemblies that throw PlatformNotSupportedException on non-Windows; the probes in
|
||||
this project guard with RuntimeInformation.IsOSPlatform(OSPlatform.Windows) so they
|
||||
return Skip on Linux/macOS rather than crashing the test host. -->
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
|
||||
<!-- net48 ships System.ServiceProcess + Microsoft.Win32 in-box via BCL references. -->
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- Microsoft.Data.SqlClient v6 supports net462+; single-target for consistency. -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user