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 }, }; }