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(); } } }