using System.Net.Sockets;
using Xunit;
using Xunit.Sdk;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
///
/// Reachability probe for a TwinCAT 3 XAR runtime on a Hyper-V VM or dedicated
/// Windows box. TCP-probes ADS port 48898 on the operator-supplied host. Tests
/// skip via /
/// when the runtime isn't reachable, so dotnet test on a fresh clone without
/// a TwinCAT VM stays green. Matches the
/// /
/// /
/// OpcPlcFixture / AbServerFixture patterns.
///
///
/// Why a VM, not a container: TwinCAT XAR bypasses the Windows
/// kernel scheduler to hit real-time PLC cycles. It can't run inside Docker, and
/// on bare metal it conflicts with Hyper-V / WSL 2 — that's why this repo's dev
/// environment puts XAR in a dedicated Hyper-V VM per
/// docs/v2/dev-environment.md §Integration host. The fixture treats the VM
/// as a black-box ADS endpoint reachable over TCP.
///
/// License rotation: the free XAR trial license expires every 7 days.
/// When it lapses the runtime goes silent + the fixture's TCP probe fails; tests
/// skip with the reason message until the operator renews via
/// TcActivate.exe /reactivate (or buys a paid runtime). Intentionally surfaces
/// as a skip rather than a hang because "trial expired" is operator action, not a
/// test failure.
///
/// Env var overrides:
///
/// - TWINCAT_TARGET_HOST — IP or hostname of the XAR VM (default
/// localhost, assumed to be unset on the average dev box + result in a
/// clean skip).
/// - TWINCAT_TARGET_NETID — AMS NetId the tests address (e.g.
/// 5.23.91.23.1.1). Seeded on the target VM via TwinCAT System
/// Manager → Routes; the dev box's AmsNetId also needs a bilateral route
/// entry on the VM side. No sensible default — tests skip if unset.
/// - TWINCAT_TARGET_PORT — ADS target port (default 851 =
/// TC3 PLC runtime 1). Set to 852 for runtime 2, etc.
///
///
public sealed class TwinCATXarFixture : IAsyncLifetime
{
private const string HostEnvVar = "TWINCAT_TARGET_HOST";
private const string NetIdEnvVar = "TWINCAT_TARGET_NETID";
private const string PortEnvVar = "TWINCAT_TARGET_PORT";
/// ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's
/// ).
public const int AdsTcpPort = 48898;
/// TC3 PLC runtime 1. Override via .
public const int DefaultAmsPort = 851;
public string TargetHost { get; }
public string? TargetNetId { get; }
public int AmsPort { get; }
public string? SkipReason { get; }
public TwinCATXarFixture()
{
TargetHost = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
TargetNetId = Environment.GetEnvironmentVariable(NetIdEnvVar);
AmsPort = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p)
? p : DefaultAmsPort;
if (string.IsNullOrWhiteSpace(TargetNetId))
{
SkipReason = $"TwinCAT XAR unreachable: {NetIdEnvVar} is not set. " +
$"Start the XAR VM + set {HostEnvVar}= and {NetIdEnvVar}=.";
return;
}
if (!TcpProbe(TargetHost, AdsTcpPort, TimeSpan.FromSeconds(2)))
{
SkipReason = $"TwinCAT XAR at {TargetHost}:{AdsTcpPort} not reachable within 2 s. " +
$"Verify the XAR VM is running, its trial license hasn't expired " +
$"(run TcActivate.exe /reactivate on the VM), and {HostEnvVar}/{NetIdEnvVar} point at it.";
}
}
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// true when the XAR runtime is reachable + the AmsNetId is set.
/// Used by the skip attributes to avoid spinning up the fixture for every test
/// class.
public static bool IsRuntimeAvailable()
{
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NetIdEnvVar))) return false;
var host = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
return TcpProbe(host, AdsTcpPort, TimeSpan.FromMilliseconds(500));
}
private static bool TcpProbe(string host, int port, TimeSpan timeout)
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(host, port);
return task.Wait(timeout) && client.Connected;
}
catch { return false; }
}
}
[Xunit.CollectionDefinition(Name)]
public sealed class TwinCATXarCollection : Xunit.ICollectionFixture
{
public const string Name = "TwinCATXar";
}
/// [Fact]-equivalent gated on .
public sealed class TwinCATFactAttribute : FactAttribute
{
public TwinCATFactAttribute()
{
if (!TwinCATXarFixture.IsRuntimeAvailable())
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
}
}
/// [Theory]-equivalent with the same gate as .
public sealed class TwinCATTheoryAttribute : TheoryAttribute
{
public TwinCATTheoryAttribute()
{
if (!TwinCATXarFixture.IsRuntimeAvailable())
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
}
}