using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; /// /// End-to-end coverage for the driver capabilities that aren't part of /// the fixed-tree path: user-authored PARAM: / MACRO: / PMC /// reads, DiscoverAsync emission, SubscribeAsync + /// OnDataChange, IAlarmSource raise/clear, and /// IHostConnectivityProbe transitions. All via the managed /// against the running focas-mock. /// [Collection(FocasSimCollection.Name)] public sealed class WireBackendCoverageTests { private readonly FocasSimFixture _fx; /// Initializes a new instance of WireBackendCoverageTests with the FOCAS simulation fixture. /// The FOCAS simulation fixture. public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx; // Derived from the fixture endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT) so the driver dials the SAME // mock the fixture probed — a remote fixture on the Docker host, not a hardcoded 127.0.0.1. private string DeviceHost => _fx.DeviceUri; /// Verifies that user tag reads route via the wire backend. [Fact] public async Task User_tag_reads_route_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { parameters = new Dictionary { ["6711"] = new { type = "long", value = 1234, @decimal = 0 }, }, macros = new Dictionary { ["500"] = new { value = 42000, @decimal = 3 }, }, pmc = new { R = new Dictionary { ["100"] = new { type = "byte", value = 7 }, }}, }, ct); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = FocasRawTags.Entries( new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false), new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false), new FocasTagDefinition("R100", DeviceHost, "R100", FocasDataType.Byte, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, }, driverInstanceId: "wire-usertags", clientFactory: new WireFocasClientFactory()); await using (drv) { await drv.InitializeAsync("{}", ct); var snaps = await drv.ReadAsync(["Param6711", "Macro500", "R100"], ct); snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); Convert.ToInt32(snaps[0].Value).ShouldBe(1234); Convert.ToDouble(snaps[1].Value).ShouldBe(42.0, tolerance: 0.001); Convert.ToInt32(snaps[2].Value).ShouldBe(7); } } /// Verifies that discover emits device folder and tag variables. [Fact] public async Task Discover_emits_device_folder_and_tag_variables() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost, DeviceName: "Lathe-1")], RawTags = FocasRawTags.Entries( new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false), new FocasTagDefinition("Speed", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, }, driverInstanceId: "wire-discover", clientFactory: new WireFocasClientFactory()); await using (drv) { await drv.InitializeAsync("{}", ct); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, ct); builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS"); builder.Folders.ShouldContain(f => f.BrowseName == DeviceHost && f.DisplayName == "Lathe-1"); builder.Variables.ShouldContain(v => v.BrowseName == "Run"); builder.Variables.ShouldContain(v => v.BrowseName == "Speed"); } } /// Verifies that subscribe fires OnDataChange via the wire backend. [Fact] public async Task Subscribe_fires_OnDataChange_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { pmc = new { R = new Dictionary { ["100"] = new { type = "byte", value = 1 }, }}, }, ct); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = FocasRawTags.Entries(new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, }, driverInstanceId: "wire-subscribe", clientFactory: new WireFocasClientFactory()); await using (drv) { await drv.InitializeAsync("{}", ct); var events = new ConcurrentQueue(); drv.OnDataChange += (_, e) => events.Enqueue(e); var handle = await drv.SubscribeAsync(["Run"], TimeSpan.FromMilliseconds(150), ct); await WaitFor(() => events.Count >= 1, TimeSpan.FromSeconds(3)); Convert.ToInt32(events.First().Snapshot.Value).ShouldBe(1); // Flip the PMC byte — next poll tick should emit a fresh OnDataChange. var before = events.Count; await _fx.PatchStateAsync(new { pmc = new { R = new Dictionary { ["100"] = new { type = "byte", value = 99 }, }}, }, ct); await WaitFor(() => events.Any(e => Convert.ToInt32(e.Snapshot.Value) == 99), TimeSpan.FromSeconds(3)); await drv.UnsubscribeAsync(handle, ct); events.Count.ShouldBeGreaterThan(before); } } /// /// Gitea #647 — CNC alarms injected into the mock drive ONE condition per ALARM: tag, keyed by the tag's /// RawPath, with a : an Overtravel alarm raises both ALARM:Overtravel /// and ALARM:*; a second (servo) alarm retriggers only the summary; clearing the list clears both. /// [Fact] public async Task Alarm_selectors_raise_retrigger_and_clear_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); // Start with no active alarms. await _fx.PatchStateAsync(new { alarms = Array.Empty() }, ct); const string overtravelPath = "Plant/Cnc/m1/overtravel"; const string anyPath = "Plant/Cnc/m1/any"; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = [AlarmTag(overtravelPath, "ALARM:Overtravel"), AlarmTag(anyPath, "ALARM:*")], Probe = new FocasProbeOptions { Enabled = false }, AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(200) }, }, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory()); await using (drv) { await drv.InitializeAsync("{}", ct); var events = new List(); drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); }; var sub = await drv.SubscribeAlarmsAsync([overtravelPath, anyPath], ct); await _fx.PatchStateAsync(new { alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" } }, }, ct); await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Raise) == 2, TimeSpan.FromSeconds(5)); await _fx.PatchStateAsync(new { alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" }, new { alm_no = 401, type = 4, axis = 2, msg = "TEST SERVO" }, }, }, ct); await WaitFor(() => Snapshot(events).Any(e => e.Kind == AlarmTransitionKind.Retrigger), TimeSpan.FromSeconds(5)); await _fx.PatchStateAsync(new { alarms = Array.Empty() }, ct); await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Clear) == 2, TimeSpan.FromSeconds(5)); await drv.UnsubscribeAlarmsAsync(sub, ct); var all = Snapshot(events); all.Where(e => e.ConditionId == overtravelPath).Select(e => e.Kind).ToArray() .ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear }); all.Where(e => e.ConditionId == anyPath).Select(e => e.Kind).ToArray() .ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear }); all[0].AlarmType.ShouldBe("Overtravel"); all[0].Severity.ShouldBe(AlarmSeverity.Critical); all.ShouldAllBe(e => e.SourceNodeId == DeviceHost); } RawTagEntry AlarmTag(string rawPath, string address) => new(rawPath, "{\"address\":\"" + address + "\",\"dataType\":\"Int32\",\"alarm\":{\"alarmType\":\"AlarmCondition\"}}", WriteIdempotent: false, DeviceName: DeviceHost); static List Snapshot(List events) { lock (events) return [.. events]; } } /// Verifies that the probe transitions to Running against the live mock. [Fact] public async Task Probe_transitions_to_Running_against_live_mock() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; var transitions = new ConcurrentQueue(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(150), Timeout = TimeSpan.FromSeconds(1), }, }, driverInstanceId: "wire-probe", clientFactory: new WireFocasClientFactory()); drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); await using (drv) { await drv.InitializeAsync("{}", ct); await WaitFor(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(5)); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); } } /// /// Verifies that the scale carried INSIDE each cnc_rddynamic2 position block /// (base 10 / exponent) scales the published AbsolutePosition, and that it wins over /// the configured PositionDecimalPlaces fallback knob. (The mock refuses the old /// cnc_getfigure 0x00D3 with EW_FUNC, as a real 31i-B does.) /// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3). /// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale. /// [Fact] public async Task Position_block_scale_scales_axis_via_wire_backend() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { axis_names = new[] { "X" }, dynamic = new { alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0, axes = new { X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 }, }, }, // Per-axis exponent served inside each 0x0026 position block (base 10). // The mock's _position_scale() reads state["position_figures"][axisName]. position_figures = new { X = 3 }, }, ct); // PositionDecimalPlaces = 1 is intentionally different from the block exponent (3) // so the assertion 12.345 uniquely proves the block's own scale won. var driver = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)], RawTags = [], Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(100), ProgramPollInterval = TimeSpan.Zero, TimerPollInterval = TimeSpan.Zero, }, }, driverInstanceId: "wire-figures", clientFactory: new WireFocasClientFactory()); await using (driver) { await driver.InitializeAsync("{}", ct); await WaitFor(() => driver.GetDeviceState(DeviceHost)?.LastFixedSnapshots .ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition") == true, TimeSpan.FromSeconds(5)); var state = driver.GetDeviceState(DeviceHost); state.ShouldNotBeNull(); var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"]; // 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won. // 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead. // 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all. published.ShouldBe(12.345, tolerance: 0.0001); } } /// /// "The control described no value" reads Bad over the real wire, never a Good number: /// a vacant 0x0026 position block (exponent 0xFFFF) Bads only its own axis /// node, an unset macro (vacant dec_val) Bads its tag, and a fractional macro on an /// integer tag is refused rather than truncated — while a normalised 9-digit macro /// mantissa de-scales to its real value. /// [Fact] public async Task Vacant_positions_and_macros_read_bad_over_the_wire() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await _fx.PatchStateAsync(new { axis_names = new[] { "X", "Y" }, dynamic = new { alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0, axes = new { X = new { absolute = 1, machine = 1, relative = 0, distance = 0 }, Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 }, }, }, position_figures = new Dictionary { ["X"] = new { exponent = 0xFFFF }, // vacant-slot marker ["Y"] = 3, }, macros = new Dictionary { ["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6 ["508"] = new { value = 1485, @decimal = 1 }, // 148.5 }, }, ct); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(DeviceHost)], RawTags = FocasRawTags.Entries( new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false), new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false), new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(100), ProgramPollInterval = TimeSpan.Zero, TimerPollInterval = TimeSpan.Zero, }, }, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory()); await using (drv) { await drv.InitializeAsync("{}", ct); await WaitFor(() => drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots .ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true, TimeSpan.FromSeconds(5)); var snaps = await drv.ReadAsync( [$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition", "Count502", "Frac508", "Unset611"], ct); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9); snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good); snaps[2].Value.ShouldBe(270); snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch); snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData); } } private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (pred()) return; await Task.Delay(50); } } private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets the list of recorded folders. public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// Gets the list of recorded variables. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// Records a folder in the address space builder. /// The browse name for the folder. /// The display name for the folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// Records a variable in the address space builder. /// The browse name for the variable. /// The display name for the variable. /// The driver attribute information. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// Records an address space property (no-op in this builder). /// The property name. /// The property data type. /// The property value. public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { /// Gets the full OPC UA reference for the variable. public string FullReference => fullRef; /// Marks the variable as an alarm condition and returns a sink. /// The alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { /// Handles an alarm transition event (no-op in this sink). /// The alarm event arguments. public void OnTransition(AlarmEventArgs args) { } } } }