274 lines
12 KiB
C#
274 lines
12 KiB
C#
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";
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
|
/// per-axis figure scaling for the F1-f cache + diagnostics surface
|
|
/// (issue #262).
|
|
/// </summary>
|
|
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
|
{
|
|
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
|
|
|
|
Task<IReadOnlyDictionary<string, int>?> 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<string, int>(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<string, int> { ["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<Task<bool>> 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) { } }
|
|
}
|
|
}
|