Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs
Joseph Doherty 7b49ea13c7 TwinCAT XAR integration fixture — scaffold the code + docs so the Hyper-V VM + .tsproj drop in without fixture-code changes. Mirrors the AB CIP Logix Emulate scaffold shipped in PR #165: tier-gated smoke tests that skip cleanly when the VM isn't reachable, a project README documenting exactly what the XAR needs to run, fixture-coverage doc promoting TwinCAT from "no integration fixture" to "scaffolded + needs operational setup". The actual Beckhoff-side work (provision VM, install XAR, author tsproj, rotate 7-day trial) lives in #221 + the new TwinCatProject/README.md walkthrough.
New project tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ with four pieces. TwinCATXarFixture — TCP probe against the ADS-over-TCP port 48898 on the host from TWINCAT_TARGET_HOST env var, requires TWINCAT_TARGET_NETID for the target AmsNetId, optional TWINCAT_TARGET_PORT for runtime 2+ (default 851 = PLC runtime 1). Doesn't own a lifecycle — XAR can't run in Docker because it bypasses the Windows kernel scheduler to hit real-time cycles, so the VM stays operator-managed. Explicit skip reasons surface the setup steps (start VM, set env vars, reactivate trial license) instead of a confusing hang. TwinCATFactAttribute + TwinCATTheoryAttribute — xunit skip gate matching AbServerFactAttribute / OpcPlcCollection patterns.

TwinCAT3SmokeTests — three smoke tests through the real AdsTwinCATClient + real ADS over TCP. Driver_reads_seeded_DINT_through_real_ADS reads GVL_Fixture.nCounter, asserts >= 1234 (MAIN increments every cycle so an exact match would race). Driver_write_then_read_round_trip_on_scratch_REAL writes 42.5 to GVL_Fixture.rSetpoint + reads back, catches the ADS write path regression that unit tests can't see. Driver_subscribe_receives_native_ADS_notifications_on_counter_changes validates the #189 native-notification path end-to-end — AddDeviceNotification fires OnDataChange at the PLC cycle boundary, the test observes one firing within 3 s. All three gated on TWINCAT_TARGET_HOST + NETID; skip via TwinCATFactAttribute when unset, verified in this commit with 3 clean [SKIP] results.

TwinCatProject/README.md — the tsproj state the smoke tests depend on. GVL_Fixture with nCounter:DINT:=1234 + rSetpoint:REAL:=0.0 + bFlag:BOOL:=TRUE; MAIN program with the single-line ladder `GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;`; PlcTask cyclic @ 10 ms priority 20; PLC runtime 1 (AMS port 851). Explains why tsproj over the compiled bootproject (text-diffable, rebuildable, no per-install state). Full XAR VM setup walkthrough — Hyper-V Gen 2 VM, TC3 XAE+XAR install, noting the AmsNetId from the tray icon, bilateral route configuration (VM System Manager → Routes + dev box StaticRoutes.xml), project import, Activate Configuration + Run Mode. License-rotation section walks through two options — scheduled TcActivate.exe /reactivate via Task Scheduler (not officially Beckhoff-supported, reportedly works on current builds) or paid runtime license (~$1k one-time per runtime per CPU). Final section shows the exact env-var recipe + dotnet test command on the dev box.

docs/drivers/TwinCAT-Test-Fixture.md — flipped TL;DR from "there is no integration fixture" to "scaffolding lives at tests/..., remaining operational work is VM + tsproj + license rotation". "What the fixture is" gains an Integration section describing the XAR VM target. "What it actually covers" gains an Integration subsection listing the three named smoke tests. Follow-up candidates rewritten — the #1 item used to be "TwinCAT 3 runtime on CI" as a speculative option; now it's concrete "XAR VM live-population" with a link to #221 + the project README for the operational walkthrough. License rotation becomes #2 with both automation paths. Key fixture / config files list adds the three new files + the project README. docs/drivers/README.md coverage-map row updated from "no integration fixture" to "XAR-VM integration scaffolding".

Solution file picks up the new tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests entry alongside the existing TwinCAT.Tests. xunit CollectionDefinition added to TwinCATXarFixture after the first build revealed the [Collection("TwinCATXar")] reference on TwinCAT3SmokeTests had no matching registration. Build 0 errors; 3 skip-clean test outcomes verified. #221 stays open as in_progress until the VM + tsproj land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:11:17 -04:00

137 lines
6.1 KiB
C#

using System.Net.Sockets;
using Xunit;
using Xunit.Sdk;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
/// <summary>
/// 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 <see cref="TwinCATFactAttribute"/> / <see cref="TwinCATTheoryAttribute"/>
/// when the runtime isn't reachable, so <c>dotnet test</c> on a fresh clone without
/// a TwinCAT VM stays green. Matches the
/// <see cref="Modbus.IntegrationTests.ModbusSimulatorFixture"/> /
/// <see cref="S7.IntegrationTests.Snap7ServerFixture"/> /
/// <c>OpcPlcFixture</c> / <c>AbServerFixture</c> patterns.
/// </summary>
/// <remarks>
/// <para><b>Why a VM, not a container</b>: 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
/// <c>docs/v2/dev-environment.md</c> §Integration host. The fixture treats the VM
/// as a black-box ADS endpoint reachable over TCP.</para>
///
/// <para><b>License rotation</b>: 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
/// <c>TcActivate.exe /reactivate</c> (or buys a paid runtime). Intentionally surfaces
/// as a skip rather than a hang because "trial expired" is operator action, not a
/// test failure.</para>
///
/// <para><b>Env var overrides</b>:
/// <list type="bullet">
/// <item><c>TWINCAT_TARGET_HOST</c> — IP or hostname of the XAR VM (default
/// <c>localhost</c>, assumed to be unset on the average dev box + result in a
/// clean skip).</item>
/// <item><c>TWINCAT_TARGET_NETID</c> — AMS NetId the tests address (e.g.
/// <c>5.23.91.23.1.1</c>). 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.</item>
/// <item><c>TWINCAT_TARGET_PORT</c> — ADS target port (default <c>851</c> =
/// TC3 PLC runtime 1). Set to <c>852</c> for runtime 2, etc.</item>
/// </list></para>
/// </remarks>
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";
/// <summary>ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's
/// <see cref="AmsPort"/>).</summary>
public const int AdsTcpPort = 48898;
/// <summary>TC3 PLC runtime 1. Override via <see cref="PortEnvVar"/>.</summary>
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}=<vm-ip> and {NetIdEnvVar}=<vm-ams-netid>.";
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;
/// <summary><c>true</c> 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.</summary>
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<TwinCATXarFixture>
{
public const string Name = "TwinCATXar";
}
/// <summary><c>[Fact]</c>-equivalent gated on <see cref="TwinCATXarFixture.IsRuntimeAvailable"/>.</summary>
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.";
}
}
/// <summary><c>[Theory]</c>-equivalent with the same gate as <see cref="TwinCATFactAttribute"/>.</summary>
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.";
}
}