fix(focas-tests): honour fixture endpoint + fix mock timer endianness (9F/1P -> 10/10)
v2-ci / build (pull_request) Successful in 4m31s
v2-ci / unit-tests (pull_request) Failing after 10m26s

Two test/fixture bugs; NO OtOpcUa driver change.

1. Topology: FocasSimFixture reads an env-overridable endpoint
   (OTOPCUA_FOCAS_SIM_ENDPOINT, default localhost:8193) and exposes Host/Port,
   but WireBackendTests + WireBackendCoverageTests hardcoded
   focas://127.0.0.1:8193. Against a remote fixture the skip-gate passed
   (remote reachable) but the driver dialed localhost -> KeyNotFound. Added a
   DeviceUri property to the fixture; each test now derives DeviceHost from it.
   Recovered 8 of 9.

2. Mock timer endianness (surfaced once #1 let the tests reach the mock): the
   last failure read 60397977600 for a 3600 s power-on timer (= 0x3C000000 x 60).
   The focas-mock's _wire_timer encoded the minute/msec fields big-endian (_u32),
   but cnc_rdtimer's timer fields are LITTLE-endian on real hardware — a
   documented, live-31i-B-validated quirk that FocasWireClient.ParseTimer decodes
   LE (docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md). The driver was
   correct; the mock was wrong. _wire_timer now emits little-endian (_u32_le).

Suite 9F/1P -> 10/10 against the remote fixture at ~/otopcua-focas.
Integration-sweep follow-up #3.
This commit is contained in:
Joseph Doherty
2026-07-15 05:51:47 -04:00
parent 00d8422cef
commit 7982043673
5 changed files with 47 additions and 18 deletions
@@ -415,7 +415,12 @@ class FocasMockServer:
timer_type = self._block_u32(request_block, 8)
key_map = {0: "power_on", 1: "operating", 2: "cutting", 3: "cycle"}
seconds = int(self.store.snapshot()["timers"].get(key_map.get(timer_type, "power_on"), 0))
return self._u32(seconds // 60) + self._u32((seconds % 60) * 1000)
# cnc_rdtimer's minute+msec fields are LITTLE-endian on the wire — unlike the big-endian
# block envelope and every other payload. Hardware-validated against a live FANUC 31i-B
# (2026-06-25); the driver's FocasWireClient.ParseTimer decodes them little-endian. Encoding
# them big-endian here (the old bug) made the driver read minute=60 as 0x3C000000. See
# docs/plans/2026-06-25-focas-pdu-v3-30i-b-support.md.
return self._u32_le(seconds // 60) + self._u32_le((seconds % 60) * 1000)
def _wire_alarms(self, request_block: bytes) -> bytes:
requested = max(self._block_u32(request_block, 12), 1)
@@ -481,6 +486,11 @@ class FocasMockServer:
def _u32(self, value: int) -> bytes:
return int(value).to_bytes(4, "big", signed=True)
def _u32_le(self, value: int) -> bytes:
# Little-endian u32 — only cnc_rdtimer's minute/msec fields use this on real hardware
# (see _wire_timer). Every other wire field is big-endian via _u32.
return int(value).to_bytes(4, "little", signed=True)
def _ascii_fixed(self, value: str, length: int) -> bytes:
return value.encode("ascii", errors="replace")[:length].ljust(length, b" ")
@@ -29,6 +29,11 @@ public sealed class FocasSimFixture : IAsyncDisposable
/// <summary>Gets the TCP port of the focas-mock simulator.</summary>
public int Port { get; }
/// <summary>Gets the <c>focas://host:port</c> device URI the driver connects to — derived from
/// <see cref="Host"/>/<see cref="Port"/> so tests honour <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (a remote
/// fixture on the Docker host) instead of assuming a localhost-colocated mock.</summary>
public string DeviceUri => $"focas://{Host}:{Port}";
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
public string? ExpectedProfile { get; }
@@ -23,7 +23,9 @@ public sealed class WireBackendCoverageTests
/// <param name="fx">The FOCAS simulation fixture.</param>
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
// Derived from the fixture endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT) so the driver dials the SAME
// mock the fixture probed — a remote fixture on the Docker host, not a hardcoded 127.0.0.1.
private string DeviceHost => _fx.DeviceUri;
/// <summary>Verifies that user tag reads route via the wire backend.</summary>
[Fact]
@@ -26,7 +26,9 @@ public sealed class WireBackendTests
/// <param name="fx">The FOCAS simulator fixture.</param>
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
// Derived from the fixture endpoint (OTOPCUA_FOCAS_SIM_ENDPOINT) so the driver dials the SAME
// mock the fixture probed — a remote fixture on the Docker host, not a hardcoded 127.0.0.1.
private string DeviceHost => _fx.DeviceUri;
/// <summary>Verifies that identity axes and dynamic data populate via the wire backend.</summary>
[Fact]