using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Phase 4 data-type tier — axis-position scaling. cnc_rddynamic2 returns /// positions as scaled integers; the driver applies a 10^PositionDecimalPlaces /// divide at the publish seam so positions surface in /// engineering units on the Float64 axis nodes. DecimalPlaces is config-supplied /// (auto-fetch via cnc_getfigure is deferred — wire-gated). /// [Trait("Category", "Unit")] public sealed class FocasPositionScalingTests { 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) { var c = new FakeFocasClient(); c.AxisNames.Clear(); c.AxisNames.Add(new("X", "")); c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based return c; } 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; } /// /// With PositionDecimalPlaces: 3 a raw AbsolutePosition of 12345 scales to /// 12.345 on the Float64 node (engineering units). /// [Fact] public async Task PositionDecimalPlaces_3_scales_AbsolutePosition_by_thousand() { var snap = new FocasDynamicSnapshot( AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0, SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0, AbsolutePosition: 12345, MachinePosition: 67890, RelativePosition: 11111, DistanceToGo: 500); var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) }; var drv = NewDriver(positionDecimalPlaces: 3, factory); await drv.InitializeAsync("{}", CancellationToken.None); var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); var mach = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/MachinePosition"); var rel = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/RelativePosition"); var dtg = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/DistanceToGo"); abs.ShouldBe(12.345); mach.ShouldBe(67.890); rel.ShouldBe(11.111); dtg.ShouldBe(0.500); await drv.ShutdownAsync(CancellationToken.None); } /// /// PositionDecimalPlaces: 0 (the default) must be byte-identical to today's /// behaviour: the integer is published unchanged, widened to double on the Float64 /// node (12345 → 12345.0). /// [Fact] public async Task PositionDecimalPlaces_0_leaves_position_unscaled() { var snap = new FocasDynamicSnapshot( AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0, SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0, AbsolutePosition: 12345, MachinePosition: 0, RelativePosition: 0, DistanceToGo: 0); var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) }; var drv = NewDriver(positionDecimalPlaces: 0, factory); await drv.InitializeAsync("{}", CancellationToken.None); var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition"); abs.ShouldBe(12345.0); await drv.ShutdownAsync(CancellationToken.None); } /// /// FeedRate (and other non-position fields) is NOT position-scaled — it surfaces /// unchanged even when PositionDecimalPlaces is set. /// [Fact] public async Task FeedRate_is_not_position_scaled() { var snap = new FocasDynamicSnapshot( AxisIndex: 1, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0, SequenceNumber: 0, ActualFeedRate: 250, ActualSpindleSpeed: 0, AbsolutePosition: 12345, MachinePosition: 0, RelativePosition: 0, DistanceToGo: 0); var factory = new FakeFocasClientFactory { Customise = () => SingleAxisClient(snap) }; var drv = NewDriver(positionDecimalPlaces: 3, factory); await drv.InitializeAsync("{}", CancellationToken.None); // Sanity: the position IS scaled on this same device. (await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition")).ShouldBe(12.345); // FeedRate must remain the raw 250, not 0.250. var feed = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/FeedRate/Actual"); feed.ShouldBe(250.0); await drv.ShutdownAsync(CancellationToken.None); } }