using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Phase 4b — per-axis auto-scale from cnc_getfigure figures. The driver fetches /// per-axis position decimal-place figures at init and applies them at the /// publish seam. Precedence: an auto figure for an axis WINS; /// the configured PositionDecimalPlaces is the per-axis fallback when the CNC did /// not report a figure for that axis. (The managed wire backend returns no figures today, /// so the real backend always uses the manual fallback — these tests drive the Fake.) /// [Trait("Category", "Unit")] public sealed class FocasPositionAutoScaleTests { private const string Host = "focas://10.0.0.5:8193"; // A single-axis CNC (X only) so the snapshot we seed is unambiguous. private static FakeFocasClient SingleAxisClient( FocasDynamicSnapshot snap, IReadOnlyList figures) { var c = new FakeFocasClient(); c.AxisNames.Clear(); c.AxisNames.Add(new("X", "")); c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based c.PositionFigures = figures; return c; } // A two-axis CNC (X, Y) — each axis gets its own seeded snapshot + figure. private static FakeFocasClient TwoAxisClient( FocasDynamicSnapshot xSnap, FocasDynamicSnapshot ySnap, IReadOnlyList figures) { var c = new FakeFocasClient(); c.AxisNames.Clear(); c.AxisNames.Add(new("X", "")); c.AxisNames.Add(new("Y", "")); c.DynamicByAxis[1] = xSnap; // axis 0 → 1-based index 1 c.DynamicByAxis[2] = ySnap; // axis 1 → 1-based index 2 c.PositionFigures = figures; return c; } private static FocasDynamicSnapshot AbsSnap(int axisIndex, int absolutePosition) => new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0, SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0, AbsolutePosition: absolutePosition, MachinePosition: 0, RelativePosition: 0, DistanceToGo: 0); private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClientFactory factory) { return new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)], Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10), }, }, "drv-1", factory); } // Drive the fixed-tree poll loop and wait (bounded) for the cached value to appear. private static async Task ReadWhenPublishedAsync(FocasDriver drv, string reference) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { var snaps = await drv.ReadAsync([reference], CancellationToken.None); var snap = snaps.Single(); if (snap.StatusCode == FocasStatusMapper.Good && snap.Value is not null) return snap.Value; await Task.Delay(15); } return null; } /// /// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3 /// (12345 → 12.345), even though PositionDecimalPlaces: 1 would have given ÷10. /// [Fact] public async Task Auto_figure_wins_over_manual_config() { var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: [3]), }; var drv = NewDriver(positionDecimalPlaces: 1, factory); await drv.InitializeAsync("{}", CancellationToken.None); var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); abs.ShouldBe(12.345); // ÷10^3 (auto), NOT 1234.5 (manual ÷10) await drv.ShutdownAsync(CancellationToken.None); } /// /// When cnc_getfigure reports nothing (empty list — the real wire backend's /// behaviour), the driver falls back to the configured PositionDecimalPlaces: /// manual 2 ⇒ ÷10^2 (12345 → 123.45). /// [Fact] public async Task Falls_back_to_manual_when_getfigure_empty() { var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []), }; var drv = NewDriver(positionDecimalPlaces: 2, factory); await drv.InitializeAsync("{}", CancellationToken.None); var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); abs.ShouldBe(123.45); // ÷10^2 (manual fallback) await drv.ShutdownAsync(CancellationToken.None); } /// /// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and /// axis 1 ÷10^1. /// [Fact] public async Task Per_axis_figures_scale_independently() { var factory = new FakeFocasClientFactory { Customise = () => TwoAxisClient( AbsSnap(1, 12345), AbsSnap(2, 67890), figures: [3, 1]), }; var drv = NewDriver(positionDecimalPlaces: 0, factory); await drv.InitializeAsync("{}", CancellationToken.None); var x = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); var y = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/Y/AbsolutePosition"); x.ShouldBe(12.345); // ÷10^3 y.ShouldBe(6789.0); // ÷10^1 await drv.ShutdownAsync(CancellationToken.None); } /// /// The legacy manual-only path is unchanged: no auto figures + PositionDecimalPlaces: 0 /// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0). /// [Fact] public async Task Manual_only_legacy_path_unchanged() { var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []), }; var drv = NewDriver(positionDecimalPlaces: 0, factory); await drv.InitializeAsync("{}", CancellationToken.None); var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); abs.ShouldBe(12345.0); // factor 1.0 — raw widened to double await drv.ShutdownAsync(CancellationToken.None); } }