test(focas): bring the vendored focas-mock onto the real 31i-B wire shapes; integration tests follow

The mock had served the shapes the driver once agreed with it on, so it
validated the very defects just fixed: it ANSWERED the non-existent 0x00D3
cnc_getfigure, wrote base/exponent 0/0 into position blocks, put the macro
decimal at offset 4, and served spindle 'max rpm' as a raw constant.

Ported from the sister ignitionoee mock (local divergence recorded in
VENDORED.md; the LE timer and alarm cap are kept):
- 0x00D3 refused with EW_FUNC, as the real control does.
- 0x0026 blocks carry base 10 / position_figures exponent; signed arg2,
  arg2 = 0 or past the axis count refused EW_ATTRIB.
- cnc_rdmacro is the 8-byte ODBM shape, 9-digit mantissa, unset = vacant.
- servo/spindle meter records carry the envelope; spindle meters are
  1/16384-of-full-scale counts (motor speed = speed / max_rpm).

Integration tests: spindle test reads Load/MotorSpeedPct percentages; the
position-scale test now proves the block's own scale wins; new
Vacant_positions_and_macros_read_bad_over_the_wire. 12/12 pass against the
mock run locally (python -m focas_mock.cli serve, OTOPCUA_FOCAS_SIM_ENDPOINT).
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:06 -04:00
parent 26b512adba
commit 9b51dbca60
5 changed files with 279 additions and 55 deletions
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
}
/// <summary>
/// Verifies that per-axis position figures fetched via <c>cnc_getfigure</c> (wire command
/// 0x00D3) scale the published <c>AbsolutePosition</c>, and that the auto figure wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob.
/// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path.
/// Verifies that the scale carried INSIDE each <c>cnc_rddynamic2</c> position block
/// (base 10 / exponent) scales the published <c>AbsolutePosition</c>, and that it wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob. (The mock refuses the old
/// <c>cnc_getfigure</c> 0x00D3 with EW_FUNC, as a real 31i-B does.)
/// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale.
/// </summary>
[Fact]
public async Task Position_figures_scale_axis_via_wire_backend()
public async Task Position_block_scale_scales_axis_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests
X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 },
},
},
// Per-axis decimal-place figures for cnc_getfigure (command 0x00D3).
// The mock's _wire_position_figures() reads state["position_figures"][axisName].
// Per-axis exponent served inside each 0x0026 position block (base 10).
// The mock's _position_scale() reads state["position_figures"][axisName].
position_figures = new { X = 3 },
}, ct);
// PositionDecimalPlaces = 1 is intentionally different from the auto figure (3)
// so the assertion 12.345 uniquely proves the cnc_getfigure path won.
// PositionDecimalPlaces = 1 is intentionally different from the block exponent (3)
// so the assertion 12.345 uniquely proves the block's own scale won.
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)],
@@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests
state.ShouldNotBeNull();
var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"];
// 12345 ÷ 10^3 = 12.345 → auto figure (3) won.
// 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won.
// 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead.
// 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all.
published.ShouldBe(12.345, tolerance: 0.0001);
}
}
/// <summary>
/// "The control described no value" reads Bad over the real wire, never a Good number:
/// a vacant <c>0x0026</c> position block (exponent <c>0xFFFF</c>) Bads only its own axis
/// node, an unset macro (vacant <c>dec_val</c>) Bads its tag, and a fractional macro on an
/// integer tag is refused rather than truncated — while a normalised 9-digit macro
/// mantissa de-scales to its real value.
/// </summary>
[Fact]
public async Task Vacant_positions_and_macros_read_bad_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X", "Y" },
dynamic = new
{
alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0,
axes = new
{
X = new { absolute = 1, machine = 1, relative = 0, distance = 0 },
Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 },
},
},
position_figures = new Dictionary<string, object>
{
["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
["Y"] = 3,
},
macros = new Dictionary<string, object>
{
["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6
["508"] = new { value = 1485, @decimal = 1 }, // 148.5
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() =>
drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots
.ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true,
TimeSpan.FromSeconds(5));
var snaps = await drv.ReadAsync(
[$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition",
"Count502", "Frac508", "Unset611"], ct);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9);
snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe(270);
snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch);
snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;