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;
///
/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish
/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the
/// spindle motor-speed meter is a live, polled MotorSpeedPct — not the bootstrap-time
/// MaxRpm constant it was mislabelled as.
///
[Trait("Category", "Unit")]
public sealed class FocasMeterPublishTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task ReadSettledAsync(FocasDriver drv, string reference, Func settled)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (settled(last)) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient MachineClient()
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.AddRange([new("X", ""), new("Y", "")]);
c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record
c.SpindleNames.Clear();
c.SpindleNames.Add(new("S", "1", "", ""));
c.SpindleLoads.Add(55.88);
c.SpindleMotorSpeeds.Add(2.49);
return c;
}
[Fact]
public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good);
var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData);
x.Value.ShouldBe(46.0);
y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
y.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live()
{
var client = MachineClient();
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
load.Value.ShouldBe(55.88);
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good))
.Value.ShouldBe(2.49);
// The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap).
client.SpindleMotorSpeeds[0] = 99.98;
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98)))
.Value.ShouldBe(99.98);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_undescribable_spindle_slot_is_bad_not_zero()
{
var client = MachineClient();
client.SpindleLoads[0] = null;
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData);
load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
load.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
var builder = new NameRecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldContain("MotorSpeedPct");
builder.Variables.ShouldNotContain("MaxRpm");
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class NameRecordingBuilder : IAddressSpaceBuilder
{
public List Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(browseName);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
}