From 7b49ea13c74ec17326963783bb84b1723ad261fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 13:11:17 -0400 Subject: [PATCH] =?UTF-8?q?TwinCAT=20XAR=20integration=20fixture=20?= =?UTF-8?q?=E2=80=94=20scaffold=20the=20code=20+=20docs=20so=20the=20Hyper?= =?UTF-8?q?-V=20VM=20+=20.tsproj=20drop=20in=20without=20fixture-code=20ch?= =?UTF-8?q?anges.=20Mirrors=20the=20AB=20CIP=20Logix=20Emulate=20scaffold?= =?UTF-8?q?=20shipped=20in=20PR=20#165:=20tier-gated=20smoke=20tests=20tha?= =?UTF-8?q?t=20skip=20cleanly=20when=20the=20VM=20isn't=20reachable,=20a?= =?UTF-8?q?=20project=20README=20documenting=20exactly=20what=20the=20XAR?= =?UTF-8?q?=20needs=20to=20run,=20fixture-coverage=20doc=20promoting=20Twi?= =?UTF-8?q?nCAT=20from=20"no=20integration=20fixture"=20to=20"scaffolded?= =?UTF-8?q?=20+=20needs=20operational=20setup".=20The=20actual=20Beckhoff-?= =?UTF-8?q?side=20work=20(provision=20VM,=20install=20XAR,=20author=20tspr?= =?UTF-8?q?oj,=20rotate=207-day=20trial)=20lives=20in=20#221=20+=20the=20n?= =?UTF-8?q?ew=20TwinCatProject/README.md=20walkthrough.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + docs/drivers/README.md | 2 +- docs/drivers/TwinCAT-Test-Fixture.md | 97 +++++++++--- .../TwinCAT3SmokeTests.cs | 135 ++++++++++++++++ .../TwinCATXarFixture.cs | 136 ++++++++++++++++ .../TwinCatProject/README.md | 145 ++++++++++++++++++ ...cUa.Driver.TwinCAT.IntegrationTests.csproj | 35 +++++ 7 files changed, 525 insertions(+), 26 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 670153c..6207782 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -38,6 +38,7 @@ + diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 899a479..4b48c19 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -45,7 +45,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni - [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped - [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102` - [AB Legacy](AbLegacy-Test-Fixture.md) — no integration fixture, unit-only via `FakeAbLegacyTag` (libplctag PCCC) -- [TwinCAT](TwinCAT-Test-Fixture.md) — no integration fixture, unit-only via `FakeTwinCATClient` with native-notification harness +- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness - [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped - [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step - [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md index ed92364..fdc2476 100644 --- a/docs/drivers/TwinCAT-Test-Fixture.md +++ b/docs/drivers/TwinCAT-Test-Fixture.md @@ -2,24 +2,62 @@ Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver. -**TL;DR: there is no integration fixture.** Every test uses a -`FakeTwinCATClient` injected via `ITwinCATClientFactory`. Beckhoff's ADS -library has no open-source simulator; ADS traffic against real TwinCAT -runtimes is trusted from field deployments. +**TL;DR:** Integration-test scaffolding lives at +`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221). +`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three +smoke tests (read / write / native notification) run end-to-end through +the real ADS stack when the VM is reachable, skip cleanly otherwise. +**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a +Hyper-V VM, author the `.tsproj` project documented at +`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a +paid runtime). Unit tests via `FakeTwinCATClient` still carry the +exhaustive contract coverage. -The silver lining: TwinCAT is the only driver outside Galaxy that uses -**native notifications** (no polling) for `ISubscribable`, and the fake -exposes a fire-event harness so notification routing is contract-tested -rigorously — just not on the wire. +TwinCAT is the only driver outside Galaxy that uses **native +notifications** (no polling) for `ISubscribable`, and the fake exposes a +fire-event harness so notification routing is contract-tested rigorously +at the unit layer. ## What the fixture is -Nothing at the integration layer. -`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is unit-only. -`FakeTwinCATClient` also fakes the `AddDeviceNotification` flow so tests can -trigger callbacks without a running runtime. +**Integration layer** (task #221, scaffolded): +`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` — +`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by +`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the +VM). No fixture-owned lifecycle — XAR can't run in Docker because it +bypasses the Windows kernel scheduler, so the VM stays +operator-managed. `TwinCatProject/README.md` documents the required +`.tsproj` project state; the file itself lands once the XAR VM is up + +the project is authored. Three smoke tests: +`Driver_reads_seeded_DINT_through_real_ADS`, +`Driver_write_then_read_round_trip_on_scratch_REAL`, and +`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes` +— all skip cleanly via `[TwinCATFact]` when the runtime isn't +reachable. -## What it actually covers (unit only) +**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is +still the primary coverage. `FakeTwinCATClient` also fakes the +`AddDeviceNotification` flow so tests can trigger callbacks without a +running runtime. + +## What it actually covers + +### Integration (XAR VM, task #221 — code scaffolded, needs VM + project) + +- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS + handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN + increments each cycle) +- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` — + real ADS write + read on `GVL_Fixture.rSetpoint` +- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes` + — real `AddDeviceNotification` against the cycle-incrementing counter; + observes `OnDataChange` firing within 3 s of subscribe + +All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env +vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or +vars are unset. + +### Unit - `TwinCATAmsAddressTests` — `ads://:` parsing + routing - `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs), @@ -89,21 +127,30 @@ back an `IAlarmSource`, but shipping that is a separate feature. ## Follow-up candidates -1. **TwinCAT 3 runtime on CI** — Beckhoff ships a free developer runtime - (7-day trial, restartable). Could run on a Windows CI runner with a - helper that auto-restarts the runtime every 6 days. Works but operational - overhead. -2. **AdsSimulator** — Beckhoff has a closed-source "ADS simulator" library - used internally; not publicly available. -3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; the - only route that covers TC2 + real notification behavior + EtherCAT I/O - effects. - -Without a rig, TwinCAT correctness is trusted from the fake matching -reality, which has held across field deployments so far. +1. **XAR VM live-population** — scaffolding is in place (this PR); the + remaining work is operational: stand up the Hyper-V VM, install XAR, + author the `.tsproj` per `TwinCatProject/README.md`, configure the + bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` + on the dev box. Then the three smoke tests transition skip → pass. + Tracked as #221. +2. **License-rotation automation** — XAR's 7-day trial expires on + schedule. Either automate `TcActivate.exe /reactivate` via a + scheduled task on the VM (not officially supported; reportedly works + for some TC3 builds), or buy a paid runtime license (~$1k one-time + per runtime per CPU) to kill the rotation. The doc at + `TwinCatProject/README.md` §License rotation walks through both. +3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network; + the only route that covers TC2 + real EtherCAT I/O timing + cycle + jitter under CPU load. ## Key fixture / config files +- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs` + — TCP probe + skip-attributes + env-var parsing +- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs` + — three wire-level smoke tests +- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md` + — project spec + VM setup + license-rotation notes - `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` — in-process fake with the notification-fire harness used by `TwinCATNativeNotificationTests` diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs new file mode 100644 index 0000000..65ebdc7 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs @@ -0,0 +1,135 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests; + +/// +/// End-to-end smoke tests against a live TwinCAT 3 XAR runtime. Skipped via +/// when the VM isn't reachable / the AmsNetId +/// isn't set. Proves the driver's AMS route setup, ADS read/write, symbol browse, +/// and native AddDeviceNotification subscription all work on the wire — +/// coverage the FakeTwinCATClient-backed unit suite can only contract-test. +/// +/// +/// Required VM project state (see TwinCatProject/README.md): +/// +/// GVL GVL_Fixture with nCounter : DINT (seed 1234), +/// rSetpoint : REAL (scratch; smoke writes + reads), bFlag : BOOL +/// (seed TRUE). +/// PLC program MAIN that increments GVL_Fixture.nCounter +/// every cycle (so the native-notification test can observe monotonic changes +/// without writing). +/// +/// +[Collection("TwinCATXar")] +[Trait("Category", "Integration")] +[Trait("Simulator", "TwinCAT-XAR")] +public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim) +{ + [TwinCATFact] + public async Task Driver_reads_seeded_DINT_through_real_ADS() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = BuildOptions(sim); + await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-read"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var snapshots = await drv.ReadAsync( + ["Counter"], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(1); + snapshots[0].StatusCode.ShouldBe(0u, + "ADS read against GVL_Fixture.nCounter must succeed end-to-end"); + // MAIN increments the counter every cycle, so the seed value (1234) is only the + // minimum we can assert — value grows monotonically. + Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(1234); + } + + [TwinCATFact] + public async Task Driver_write_then_read_round_trip_on_scratch_REAL() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = BuildOptions(sim); + await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-write"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + const float probe = 42.5f; + var writeResults = await drv.WriteAsync( + [new WriteRequest("Setpoint", probe)], + TestContext.Current.CancellationToken); + writeResults.Count.ShouldBe(1); + writeResults[0].StatusCode.ShouldBe(0u); + + var readResults = await drv.ReadAsync( + ["Setpoint"], TestContext.Current.CancellationToken); + readResults.Count.ShouldBe(1); + readResults[0].StatusCode.ShouldBe(0u); + Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f); + } + + [TwinCATFact] + public async Task Driver_subscribe_receives_native_ADS_notifications_on_counter_changes() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + + var options = BuildOptions(sim); + await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-sub"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var observed = new List(); + var gate = new SemaphoreSlim(0); + drv.OnDataChange += (_, e) => + { + lock (observed) observed.Add(e); + gate.Release(); + }; + + var handle = await drv.SubscribeAsync( + ["Counter"], TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + + // MAIN increments the counter every PLC cycle (default 10 ms task tick). + // Native ADS notifications fire on cycle boundaries so 3 s is generous for + // at least one OnDataChange to land. + var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 3 s of subscribe"); + + int observedCount; + lock (observed) observedCount = observed.Count; + observedCount.ShouldBeGreaterThan(0); + + await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); + } + + private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new() + { + Devices = [ + new TwinCATDeviceOptions( + HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", + DeviceName: "XAR-VM"), + ], + Tags = [ + new TwinCATTagDefinition( + Name: "Counter", + DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", + SymbolPath: "GVL_Fixture.nCounter", + DataType: TwinCATDataType.DInt), + new TwinCATTagDefinition( + Name: "Setpoint", + DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", + SymbolPath: "GVL_Fixture.rSetpoint", + DataType: TwinCATDataType.Real, + Writable: true), + ], + UseNativeNotifications = true, + Timeout = TimeSpan.FromSeconds(5), + // Disable the probe loop — the smoke tests run their own reads; a background + // probe against GVL_Fixture.nCounter would race with them for the ADS client + // gate + inject flakiness unrelated to the code under test. + Probe = new TwinCATProbeOptions { Enabled = false }, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs new file mode 100644 index 0000000..5df1323 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs @@ -0,0 +1,136 @@ +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."; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md new file mode 100644 index 0000000..1b019d4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md @@ -0,0 +1,145 @@ +# TwinCAT XAR fixture project + +This folder holds the TwinCAT 3 XAE project that the XAR VM runs for the +integration-tests suite (`tests/.../TwinCAT.IntegrationTests/*.cs`). + +**Status today**: stub. The `.tsproj` isn't committed yet; once the XAR +VM is up + a project with the required state exists, export via +File → Export + drop it here as `OtOpcUaTwinCatFixture.tsproj` + its +PLC `.library` / `.plcproj` companions. + +## Why `.tsproj`, not the binary bootproject + +TwinCAT ships two project forms: the XAE `.tsproj` (XML, source of +truth) and the compiled bootproject that the XAR runtime actually +loads. Ship the `.tsproj` because: + +- Text format — reviewable in PR diffs, diffable in git +- Rebuildable across TC3 engineering versions (the XAE tool rebuilds + the bootproject from `.tsproj` on "Activate Configuration") +- Doesn't carry per-install state (target AmsNetId, source licensing) + +Reconstruction workflow on the VM: + +1. Open TC3 XAE (Visual Studio shell) +2. File → Open → `OtOpcUaTwinCatFixture.tsproj` +3. Target system → the VM's AmsNetId (set in System Manager → Routes) +4. Build → Build Solution (produces the bootproject) +5. Activate Configuration → Run Mode (deploys to XAR + starts the + runtime) + +## Required project state + +The smoke tests in `TwinCAT3SmokeTests.cs` depend on this exact GVL + +PLC setup. Missing or renamed symbols surface as ADS `DeviceSymbolNotFound` +or wrong-type read failures, not silent skips. + +### Global Variable List: `GVL_Fixture` + +```st +VAR_GLOBAL + // Monotonically-increasing counter; MAIN increments each cycle. + // Seed value 1234 picked so the smoke test can assert ">= 1234" without + // synchronising with the initial cycle. + nCounter : DINT := 1234; + + // Scratch REAL for write-then-read round-trip test. Smoke test writes + // 42.5 + reads back. + rSetpoint : REAL := 0.0; + + // Readable boolean with seed value TRUE. Reserved for future + // expansion (e.g. discovery / symbol-browse tests). + bFlag : BOOL := TRUE; +END_VAR +``` + +### PLC program: `MAIN` + +```st +PROGRAM MAIN +VAR +END_VAR + +// One-line program: increment the fixture counter every cycle. +// The native-notification smoke test subscribes to GVL_Fixture.nCounter +// + observes the monotonic changes without a write path. +GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1; +``` + +### Task + +- `PlcTask` — cyclic, 10 ms interval, priority 20 +- Assigned to `MAIN` + +### Runtime ID + +- TC3 PLC runtime 1 (AMS port `851`) — the smoke-test fixture defaults + to this. Use runtime 2 / port `852` only if the single runtime is + already taken by another project on the same VM. + +## XAR VM setup (one-time) + +Full bootstrap lives in `docs/v2/dev-environment.md`. The TwinCAT-specific +steps: + +1. **Create the Hyper-V VM** — Gen 2, Windows 10/11 64-bit, 4 GB RAM, + 2 CPUs. External virtual switch so the dev box can reach + `:48898`. +2. **Install TwinCAT 3 XAE + XAR** — free download from Beckhoff + (`www.beckhoff.com/en-en/products/automation/twincat/`). Activate the + 7-day trial on first boot. +3. **Note the VM's AmsNetId** — shown in the TwinCAT system tray icon → + Properties → AMS NetId (format like `5.23.91.23.1.1`). +4. **Configure bilateral ADS route**: + - On the VM: System Manager → Routes → Add Route → dev box's + AmsNetId + IP + - On the dev box: edit `%TC_INSTALLPATH%\Target\StaticRoutes.xml` (or + use the dev box's own TwinCAT System Manager if installed) to add + the VM's AmsNetId + IP +5. **Import this project** per the reconstruction workflow above. +6. **Hit Activate Configuration + Run Mode**. The runtime starts; the + system tray icon goes green; port `48898` is live. + +## License rotation + +The XAR trial expires every 7 days. When it lapses: + +1. The runtime goes silent (red tray icon, ADS port `48898` stops + responding to new connections). +2. Integration tests skip with the reason message pointing at this + folder's README. +3. Operator runs `C:\TwinCAT\3.1\Target\StartUp\TcActivate.exe /reactivate` + on the VM console (not RDP — the trial activation wants the + interactive-login desktop). + +Options to eliminate the manual step: + +- **Scheduled task** that runs the reactivate every 6 days at 02:00 — + documented in the Beckhoff forums as working for some TC3 builds, + not officially supported. +- **Paid runtime license** (~$1k one-time per runtime, per CPU) — kills + the rotation permanently, worth it if the integration host is + long-lived. + +## How to run the TwinCAT-tier tests + +On the dev box: + +```powershell +$env:TWINCAT_TARGET_HOST = '10.0.0.42' # replace with the VM IP +$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' # replace with the VM AmsNetId +# $env:TWINCAT_TARGET_PORT = '852' # only if not using PLC runtime 1 +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests +``` + +With any of those env vars unset, all three smoke tests skip cleanly via +`[TwinCATFact]`; unit suite (`TwinCAT.Tests`) runs unchanged. + +## See also + +- [`docs/drivers/TwinCAT-Test-Fixture.md`](../../../docs/drivers/TwinCAT-Test-Fixture.md) + — coverage map +- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) + §Integration host — VM + route + license-rotation notes +- Beckhoff Information System → TwinCAT 3 → Product overview + ADS + + PLC reference (licensed; internal link only) diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj new file mode 100644 index 0000000..cfb2824 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + -- 2.49.1