using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasFigureScalingDiagnosticsTests { private const string Host = "focas://10.0.0.7:8193"; /// /// Variant of that returns configurable /// per-axis figure scaling for the F1-f cache + diagnostics surface /// (issue #262). /// private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient { public IReadOnlyDictionary? Scaling { get; set; } Task?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) => Task.FromResult(Scaling); } [Fact] public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-diag", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics"); var diagVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Diagnostics/")).ToList(); diagVars.Count.ShouldBe(5); // Verify per-field types match the documented surface (Int64 counters, // String error message, DateTime last-success timestamp). diagVars.Single(v => v.BrowseName == "ReadCount") .Info.DriverDataType.ShouldBe(DriverDataType.Int64); diagVars.Single(v => v.BrowseName == "ReadFailureCount") .Info.DriverDataType.ShouldBe(DriverDataType.Int64); diagVars.Single(v => v.BrowseName == "ReconnectCount") .Info.DriverDataType.ShouldBe(DriverDataType.Int64); diagVars.Single(v => v.BrowseName == "LastErrorMessage") .Info.DriverDataType.ShouldBe(DriverDataType.String); diagVars.Single(v => v.BrowseName == "LastSuccessfulRead") .Info.DriverDataType.ShouldBe(DriverDataType.DateTime); foreach (var v in diagVars) v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } [Fact] public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks() { // Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead; // ReconnectCount bumps once on the initial connect (issue #262). var fake = new FakeFocasClient { ProbeResult = true }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) }, }, "drv-diag-read", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically. await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); return snap.Value is long n && n >= 2; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount", $"{Host}::Diagnostics/ReconnectCount", $"{Host}::Diagnostics/LastErrorMessage", $"{Host}::Diagnostics/LastSuccessfulRead", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); ((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2); ((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe ((long)snaps[2].Value!).ShouldBe(1); // one initial connect snaps[3].Value.ShouldBe(string.Empty); ((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue); foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false() { // ProbeResult=false → success branch is skipped, ReadFailureCount bumps each // tick. The connect itself succeeded so ReconnectCount is 1. var fake = new FakeFocasClient { ProbeResult = false }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) }, }, "drv-diag-fail", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single(); return snap.Value is long n && n >= 2; }, TimeSpan.FromSeconds(3)); var snaps = await drv.ReadAsync( [$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None); ((long)snaps[0].Value!).ShouldBe(0); ((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places() { // Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling // default is true → rawValue / 10^dec for the named axis (issue #262). var fake = new FigureAwareFakeFocasClient { Scaling = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000) ["axis2"] = 4, // Y-axis: 4 decimal places }, }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) }, }, "drv-fig", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Wait for the probe-tick path to populate the cache (one successful tick is // enough — the figure-scaling read happens whenever the cache is null). await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); return snap.Value is long n && n >= 1; }, TimeSpan.FromSeconds(3)); // 100000 / 10^3 = 100.0 mm drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0); // 250000 / 10^4 = 25.0 mm drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0); // Unknown axis → raw value passes through. drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false() { // ApplyFigureScaling=false short-circuits before the cache lookup so the raw // integer is published unchanged. Migration parity for deployments that already // surfaced raw values from older drivers (issue #262). var fake = new FigureAwareFakeFocasClient { Scaling = new Dictionary { ["axis1"] = 3 }, }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) }, FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false }, }, "drv-fig-off", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single(); return snap.Value is long n && n >= 1; }, TimeSpan.FromSeconds(3)); // Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false // means the raw value passes through unchanged. drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected() { // Construction is licence-safe (no DLL load); the unconnected client must // short-circuit before P/Invoke so the driver leaves the cache untouched. var client = new FwlibFocasClient(); (await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull(); } [Fact] public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer() { // Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per // fwlib32.h each axis entry is { short dec, short unit, short reserved, // short reserved2 } = 8 bytes; we only read dec. var buf = new byte[FwlibNative.MAX_AXIS * 8]; // Axis 1: dec=3 buf[0] = 3; buf[1] = 0; // Axis 2: dec=4 buf[8] = 4; buf[9] = 0; // Axis 3: dec=0 (already zero) var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3); map.Count.ShouldBe(3); map["axis1"].ShouldBe(3); map["axis2"].ShouldBe(4); map["axis3"].ShouldBe(0); // Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't // overrun the buffer. var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99); clamped.Count.ShouldBe(FwlibNative.MAX_AXIS); } private static async Task WaitForAsync(Func> condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!await condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); 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 _, DriverDataType __, object? ___) { } 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) { } } } }