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, /// native AddDeviceNotification subscription, array addressing, auto-reconnect, /// full primitive type mapping, and the DiscoverAsync→address-space pipeline 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). /// GVL GVL_Primitives with one of every ADS primitive at its seed value. /// GVL GVL_Arrays with aReal1D : ARRAY[0..31] OF REAL (scratch; array /// round-trip test writes element 5). /// 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"); // Value is a DINT — we only assert the read path returned an integer. Don't // pin to the 1234 seed: the PlcTask may watchdog-restart and reset counters // to 0, and we care about end-to-end transport here, not PLC uptime. Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(0); } [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(500), TestContext.Current.CancellationToken); // We only assert the transport wires up and at least one notification lands. // Don't pin to PLC cycle time: if PlcTask watchdog-restarts or runs slower than // its nominal 10 ms, increments may be coarse — but the counter still changes, // so any reasonable window catches it. 10 s leaves headroom for transient PLC // restarts without turning into a test hang. var got = await gate.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 10 s of subscribe"); int observedCount; lock (observed) observedCount = observed.Count; observedCount.ShouldBeGreaterThan(0); await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } [TwinCATFact] public async Task Driver_browses_committed_symbol_hierarchy_via_real_ADS() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Goes straight at the wire client rather than TwinCATDriver.DiscoverAsync: the // discover flow funnels symbols into an IAddressSpaceBuilder, which doesn't give // us a flat list to assert against. BrowseSymbolsAsync is what that flow calls // internally, so covering it here covers the same transport path. using var client = new AdsTwinCATClient(); await client.ConnectAsync( new TwinCATAmsAddress(sim.TargetNetId!, sim.AmsPort), TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); var symbols = new List(); await foreach (var s in client.BrowseSymbolsAsync(TestContext.Current.CancellationToken)) symbols.Add(s); symbols.ShouldNotBeEmpty("BrowseSymbolsAsync must yield at least the committed GVL symbols"); // Spot-check the smoke-test contract: GVL_Fixture.nCounter (DINT, writable). var counter = symbols.FirstOrDefault(s => s.InstancePath == "GVL_Fixture.nCounter"); counter.ShouldNotBeNull("GVL_Fixture.nCounter must surface in the symbol table"); counter.DataType.ShouldBe(TwinCATDataType.DInt); // Primitive coverage — every ADS primitive is committed under GVL_Primitives. Prove // the type mapper catches a couple of representative entries (BOOL, REAL, STRING). symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vBool" && s.DataType == TwinCATDataType.Bool); symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vReal" && s.DataType == TwinCATDataType.Real); symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vString" && s.DataType == TwinCATDataType.String); // Array coverage — SymbolLoaderFactory (Flat mode) expands arrays to per-element // paths, so at least one element of the 2-D REAL array should appear. symbols.ShouldContain(s => s.InstancePath.StartsWith("GVL_Arrays.aReal2D", StringComparison.Ordinal)); // Enum / alias coverage — GVL_Enums roots one of each so we don't have to walk plants. symbols.ShouldContain(s => s.InstancePath == "GVL_Enums.currentAxisState"); } [TwinCATFact] public async Task Driver_round_trips_array_element_write_and_read() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Arrays are a high-traffic PLC pattern but no earlier test hits an indexed symbol // end-to-end. Round-trip into GVL_Arrays.aReal1D[5] — the subscript flows through // TwinCATSymbolPath.TryParse → AdsSymbolName ("GVL_Arrays.aReal1D[5]") which is // the ADS wire syntax the SymbolLoader emits for per-element access. var options = BuildOptions(sim); await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-array"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); const float probe = 73.25f; var writeResults = await drv.WriteAsync( [new WriteRequest("ArrayElem", probe)], TestContext.Current.CancellationToken); writeResults.Count.ShouldBe(1); writeResults[0].StatusCode.ShouldBe(0u, "array-element write must succeed against aReal1D[5]"); var readResults = await drv.ReadAsync( ["ArrayElem"], TestContext.Current.CancellationToken); readResults[0].StatusCode.ShouldBe(0u); Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f); } [TwinCATFact] public async Task Driver_auto_reconnects_after_underlying_client_is_disposed() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Reconnect is the most operationally important durability property — ADS links // drop (router restarts, VM reboots, TCP resets). EnsureConnectedAsync creates a // fresh AdsClient when the prior one is gone, but until this test the live-wire // recovery path was only unit-tested against FakeTwinCATClient. var options = BuildOptions(sim); await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-reconnect"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var first = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken); first[0].StatusCode.ShouldBe(0u); // Dispose the underlying client the driver is holding. Next read must reconnect // (via EnsureConnectedAsync → AdsClient.Connect) rather than fail. var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}"; var device = drv.GetDeviceState(hostAddress).ShouldNotBeNull("device state must exist after Initialize"); device.DisposeClient(); var second = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken); second[0].StatusCode.ShouldBe(0u, "driver must transparently reconnect on next read"); } [TwinCATTheory] // vBool's expected value is null — the initial TRUE seed doesn't reliably survive cold // restarts on this deployment, and the point of this theory is round-tripping the type // mapper, not test-data seed persistence. All other primitives re-init on every boot. [InlineData("GVL_Primitives.vBool", TwinCATDataType.Bool, null)] [InlineData("GVL_Primitives.vSInt", TwinCATDataType.SInt, "-42")] [InlineData("GVL_Primitives.vUSInt", TwinCATDataType.USInt, "250")] [InlineData("GVL_Primitives.vInt", TwinCATDataType.Int, "-12345")] [InlineData("GVL_Primitives.vUInt", TwinCATDataType.UInt, "54321")] [InlineData("GVL_Primitives.vDInt", TwinCATDataType.DInt, "-1234567")] [InlineData("GVL_Primitives.vUDInt", TwinCATDataType.UDInt, "4000000000")] [InlineData("GVL_Primitives.vLInt", TwinCATDataType.LInt, "-1234567890123")] [InlineData("GVL_Primitives.vULInt", TwinCATDataType.ULInt, "12345678901234567")] [InlineData("GVL_Primitives.vReal", TwinCATDataType.Real, "3.14159")] [InlineData("GVL_Primitives.vLReal", TwinCATDataType.LReal, "2.7182818284590452")] [InlineData("GVL_Primitives.vString", TwinCATDataType.String, "Hello from TC3")] [InlineData("GVL_Primitives.vTime", TwinCATDataType.Time, null)] [InlineData("GVL_Primitives.vTimeOfDay", TwinCATDataType.TimeOfDay, null)] [InlineData("GVL_Primitives.vDate", TwinCATDataType.Date, null)] [InlineData("GVL_Primitives.vDateTime", TwinCATDataType.DateTime, null)] public async Task Driver_reads_every_primitive_type_with_correct_mapping( string symbolPath, TwinCATDataType type, string? expectedValueInvariant) { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // One driver per case is ~0.3 s each on the live VM — pragmatic vs a shared-driver // iteration pattern since the point is to exercise options/tag-mapping/ReadAsync // end-to-end per primitive, catching regressions like the STRING(N) mapper bug. var options = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")], Tags = [ new TwinCATTagDefinition( Name: "Primitive", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: symbolPath, DataType: type), ], Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = false }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: $"tc3-prim-{type}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var result = await drv.ReadAsync(["Primitive"], TestContext.Current.CancellationToken); result[0].StatusCode.ShouldBe(0u, $"primitive read must succeed for {symbolPath} ({type})"); result[0].Value.ShouldNotBeNull(); // Expected-value check only where the seed is ergonomic to re-encode as a literal — // TIME / TOD / DATE / DT arrive as uint ticks, which is covered by the status+type // assertions above; adding brittle tick-math here adds no signal over that. if (expectedValueInvariant is not null) { switch (type) { case TwinCATDataType.Bool: result[0].Value.ShouldBe(bool.Parse(expectedValueInvariant)); break; case TwinCATDataType.String: result[0].Value.ShouldBe(expectedValueInvariant); break; case TwinCATDataType.Real: Convert.ToSingle(result[0].Value).ShouldBe( float.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture), tolerance: 0.0001f); break; case TwinCATDataType.LReal: Convert.ToDouble(result[0].Value).ShouldBe( double.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture), tolerance: 0.0000001); break; default: // Integer primitives: parse against the target CLR type the mapper chose. Convert.ToInt64(result[0].Value).ShouldBe(long.Parse(expectedValueInvariant)); break; } } } [TwinCATFact] public async Task Driver_reads_bit_indexed_BOOL_from_word() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // GVL_Primitives.vWord = 0xBEEF = 1011 1110 1110 1111. Bit 3 = 1 (within the low // nibble 'F'). Tags with bitIndex route through ExtractBit → TwinCAT.Ads supports // the .N bit-access suffix natively so the driver's read path relies on that. var options = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")], Tags = [ new TwinCATTagDefinition( Name: "WordBit3", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Primitives.vWord.3", DataType: TwinCATDataType.Bool), new TwinCATTagDefinition( Name: "WordBit4", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Primitives.vWord.4", DataType: TwinCATDataType.Bool), ], Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = false }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-bitbool"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var result = await drv.ReadAsync(["WordBit3", "WordBit4"], TestContext.Current.CancellationToken); result[0].StatusCode.ShouldBe(0u); result[0].Value.ShouldBe(true, "bit 3 of 0xBEEF is set"); result[1].StatusCode.ShouldBe(0u); result[1].Value.ShouldBe(false, "bit 4 of 0xBEEF is clear"); } [TwinCATFact] public async Task Driver_reads_deeply_nested_UDT_path() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // 5-level nested path into the plant hierarchy. FB_LineSim is driving the motor // temperature from a sine of the counter, so the value is alive but bounded. var options = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")], Tags = [ new TwinCATTagDefinition( Name: "MotorTemp", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature", DataType: TwinCATDataType.LReal), new TwinCATTagDefinition( Name: "MotorRunning", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Running", DataType: TwinCATDataType.Bool), ], Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = false }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-udt"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var result = await drv.ReadAsync(["MotorTemp", "MotorRunning"], TestContext.Current.CancellationToken); result[0].StatusCode.ShouldBe(0u, "nested UDT LREAL path must round-trip"); result[0].Value.ShouldBeOfType(); result[1].StatusCode.ShouldBe(0u, "nested UDT BOOL path must round-trip"); result[1].Value.ShouldBeOfType(); } [TwinCATFact] public async Task Driver_reports_errors_for_unknown_tag_and_nonexistent_symbol_and_readonly_write() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Three negative paths in one driver instance — cheaper than spinning three drivers // for what are essentially status-code assertions: // 1. unknown tag name (not in the options map) → BadNodeIdUnknown // 2. known tag pointing at a nonexistent PLC symbol → ADS error mapped to non-zero // 3. write against a Writable=false tag → BadNotWritable var options = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")], Tags = [ new TwinCATTagDefinition( Name: "NonexistentSymbol", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_DoesNotExist.vGhost", DataType: TwinCATDataType.DInt), new TwinCATTagDefinition( Name: "ReadOnlyCounter", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Fixture.nCounter", DataType: TwinCATDataType.DInt, Writable: false), ], Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = false }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-errors"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // (1) Unknown tag — never registered in options, must short-circuit before ADS. var unknownRead = await drv.ReadAsync(["NeverDeclared"], TestContext.Current.CancellationToken); unknownRead[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); // (2) Known tag, nonexistent PLC symbol — ADS returns SymbolNotFound, driver maps it // to a non-zero OPC UA status. Don't pin to a specific code — the exact mapping // is a driver-internal concern and we only care it surfaces as an error. var ghostRead = await drv.ReadAsync(["NonexistentSymbol"], TestContext.Current.CancellationToken); ghostRead[0].StatusCode.ShouldNotBe(0u, "nonexistent PLC symbol must surface as a non-Good status"); // (3) Read-only declared tag — write must short-circuit with BadNotWritable before ADS. var roWrite = await drv.WriteAsync( [new WriteRequest("ReadOnlyCounter", 999)], TestContext.Current.CancellationToken); roWrite[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); } [TwinCATFact] public async Task Driver_routes_reads_per_device_and_isolates_unreachable_peers() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Two devices in one driver: the real VM + a bogus AmsNetId that won't resolve. Tags // routed to the real device must still succeed; tags on the unreachable device must // surface comms errors without poisoning the healthy one — this is the multi-device // routing + per-device connection isolation contract the unit suite can't prove on the wire. var realHost = $"ads://{sim.TargetNetId}:{sim.AmsPort}"; var ghostHost = "ads://99.99.99.99.1.1:851"; var options = new TwinCATDriverOptions { Devices = [ new TwinCATDeviceOptions(realHost, "Real-VM"), new TwinCATDeviceOptions(ghostHost, "Ghost-VM"), ], Tags = [ new TwinCATTagDefinition( Name: "RealCounter", DeviceHostAddress: realHost, SymbolPath: "GVL_Fixture.nCounter", DataType: TwinCATDataType.DInt), new TwinCATTagDefinition( Name: "GhostCounter", DeviceHostAddress: ghostHost, SymbolPath: "GVL_Fixture.nCounter", DataType: TwinCATDataType.DInt), ], // Shorter timeout so the bogus device fails fast rather than dragging the whole // test; the healthy read shouldn't be slowed down by a peer timeout. Timeout = TimeSpan.FromSeconds(2), Probe = new TwinCATProbeOptions { Enabled = false }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-multidev"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var real = await drv.ReadAsync(["RealCounter"], TestContext.Current.CancellationToken); real[0].StatusCode.ShouldBe(0u, "healthy device read must succeed alongside unreachable peer"); var ghost = await drv.ReadAsync(["GhostCounter"], TestContext.Current.CancellationToken); ghost[0].StatusCode.ShouldNotBe(0u, "unreachable device read must surface non-Good status"); // Per-device host resolver — each tag's resolved host matches the device it was // declared against, regardless of the order reads arrive. drv.ResolveHost("RealCounter").ShouldBe(realHost); drv.ResolveHost("GhostCounter").ShouldBe(ghostHost); } [TwinCATFact] public async Task Probe_loop_raises_host_status_transition_to_Running_on_reachable_target() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Turn the probe loop on — InitializeAsync kicks off a background task per device // that calls AdsClient.ReadState. On first success the driver fires an // OnHostStatusChanged(Unknown|Stopped → Running). We only need to see one transition // to Running to prove the probe + event wiring is live. var options = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")], Tags = [], Timeout = TimeSpan.FromSeconds(5), Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromSeconds(2), }, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-probe"); var runningSeen = new TaskCompletionSource(); drv.OnHostStatusChanged += (_, e) => { if (e.NewState == HostState.Running) runningSeen.TrySetResult(true); }; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // 5 s headroom: first probe fires ~250 ms after Initialize, plus ADS connect handshake. var completed = await Task.WhenAny( runningSeen.Task, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); completed.ShouldBe(runningSeen.Task, "probe loop must raise a Running transition within 5 s"); // Snapshot should also reflect Running — proves GetHostStatuses is in sync with the event. var statuses = drv.GetHostStatuses(); statuses.Count.ShouldBe(1); statuses[0].State.ShouldBe(HostState.Running); } [TwinCATFact] public async Task DiscoverAsync_renders_declared_tags_and_controller_browse_hits_address_space_builder() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Hits the DiscoverAsync → IAddressSpaceBuilder pipeline the browse-only test bypasses. // With EnableControllerBrowse=true, the driver must (a) emit the pre-declared tag under // the device folder and (b) drop discovered symbols into a sibling "Discovered/" folder. var baseOptions = BuildOptions(sim); var options = new TwinCATDriverOptions { Devices = baseOptions.Devices, Tags = baseOptions.Tags, UseNativeNotifications = baseOptions.UseNativeNotifications, Timeout = baseOptions.Timeout, Probe = baseOptions.Probe, EnableControllerBrowse = true, }; await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-discover"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingAddressSpaceBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); // Structural folders in the order the driver emits them: TwinCAT → device → Discovered. builder.FolderBrowseNames.ShouldContain("TwinCAT"); builder.FolderBrowseNames.ShouldContain($"ads://{sim.TargetNetId}:{sim.AmsPort}"); builder.FolderBrowseNames.ShouldContain("Discovered"); // Pre-declared tag from TwinCATDriverOptions.Tags — always emitted regardless of browse. builder.Variables.ShouldContain(v => v.BrowseName == "Counter" && v.Info.DriverDataType == DriverDataType.Int32); // Controller-discovered symbol — GVL_Fixture.nCounter lands under Discovered/. builder.Variables.ShouldContain(v => v.BrowseName == "GVL_Fixture.nCounter" && v.Info.DriverDataType == DriverDataType.Int32); } 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), new TwinCATTagDefinition( Name: "ArrayElem", DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}", SymbolPath: "GVL_Arrays.aReal1D[5]", 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 }, }; } /// /// Test double that captures every / /// call the driver makes during /// DiscoverAsync. Lets assertions inspect the resulting folder + variable tree /// without materializing an OPC UA node manager. /// internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = []; public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = []; public IEnumerable FolderBrowseNames => Folders.Select(f => f.BrowseName); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string name, DriverDataType type, object? value) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } }