Files
lmxopcua/docs/drivers/FOCAS-Test-Fixture.md
Joseph Doherty 7dd64b8ccd feat(focas): OtOpcUa-owned otopcua-focas-fixture stack (#651) + FOCAS backlog docs (#633)
Fixture renamed off ignitionoee's otopcua-focas-sim, port 8193 published, project label,
default endpoint 10.100.0.35:8193; infra/dev-environment docs. FOCAS.md: MACRO:3901 parts
count, PMC override + cycle-time recipes, won't-do/blocked backlog.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:12:13 -04:00

7.8 KiB

FOCAS test fixture

Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.

OtOpcUa speaks FOCAS2 directly over TCP via the pure-managed Focas.Wire client. Integration tests run the managed driver end-to-end against the vendored focas-mock Python server (at tests/.../Docker/focas-mock/) whose native FOCAS Ethernet responder is verified PDU-by-PDU against the real fwlibe64.dll.

No shim DLL, no P/Invoke, no licensed binary — any dev box or CI runner with Docker can run the full fixture end-to-end.

Hardware validation against a real CNC is still useful to catch series-specific firmware quirks (see § Hardware-only gaps) but the mock's wire responder covers every FOCAS call OtOpcUa issues.

What the fixture covers

Unit layer (no container required)

tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ uses FakeFocasClient injected via IFocasClientFactory:

  • FocasCapabilityTests — data-type mapping (PMC bit / byte / word / long / float / double, macro variable types, parameter types)
  • FocasCapabilityMatrixTests — per-CNC-series range validation across 16i / 0i-D / 0i-F / 30i / Power Motion, 46 theory cases locking every documented range boundary. See docs/v2/focas-version-matrix.md.
  • FocasReadWriteTests — read / write contract against the fake, FOCAS native status → OPC UA StatusCode mapping
  • FocasScaffoldingTests — IDriver lifecycle + multi-device routing
  • FocasPmcBitRmwTests — PMC bit read-modify-write synchronisation
  • FocasAlarmProjectionTests — raise / clear diffing, severity mapping
  • FocasHandleRecycleTests — proactive session recycle cadence

Capability surfaces whose contract is verified: IDriver, IReadable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource. IWritable intentionally returns BadNotWritable — OtOpcUa is read-only against FOCAS.

Pre-flight validation runs in FocasDriver.InitializeAsync — configs referencing out-of-range addresses fail at load time with a diagnostic message naming the CNC series + documented limit.

Integration layer (mock only, no CNC, no shim)

tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ drives the managed FocasDriver end-to-end. A single gate:

Docker compose up — tests skip when the TCP probe fails, with a pointer to the compose command. The endpoint defaults to 10.100.0.35:8193 (the OtOpcUa-owned otopcua-focas-fixture container, stack /opt/otopcua-focas; see infra/README.md) and is overridable via OTOPCUA_FOCAS_SIM_ENDPOINT.

When the mock is up, WireFocasClient dials it over TCP exactly like a real CNC, and the mock's native FOCAS Ethernet responder replies with binary PDUs against the documented command IDs. Covered assertions:

  • Session open / close (cnc_allclibhndl3 + cnc_freelibhndl)
  • Parameter read-back after mock_patch seed → cnc_rdparam
  • Macro read-back after seed → cnc_rdmacro (8-byte ODBM shape: 9-digit mantissa, dec_val at offset 6; vacant / fractional-on-integer macros read Bad)
  • PMC range read after seed → pmc_rdpmcrng
  • IAlarmSource raise + clear transitions after mock_patch alarm-list changes → cnc_rdalmmsg2
  • Fixed-tree bootstrap: identity / axes / spindle / program / timers / servo meters populate via cnc_sysinfo, cnc_rdaxisname, cnc_rdspdlname, cnc_rddynamic2, cnc_exeprgname2, cnc_rdblkcount, cnc_rdopmode, cnc_rdsvmeter, cnc_rdspmeter (load + motor speed, 1/16384 of full scale), cnc_rdtimer; positions scale from each cnc_rddynamic2 block's own base/exponent (the mock refuses the non-existent cnc_getfigure 0x00D3, as a real 31i-B does). The vendored mock's local divergence from upstream is listed in Docker/focas-mock/VENDORED.md.
  • Per-series profile selection via mock_load_profile — tests can pin one profile and assert series-gated capability suppression

E2E script (CLI)

scripts/e2e/test-focas.ps1 drives the Client.CLI against a running OtOpcUa server. Accepts:

  • -CncHost / -CncPort for real hardware
  • -ProfileName <compose-profile> for the Docker mock
  • -Series <csv> for per-series matrix mode
  • -HandleLeakCycles <N> for handle-leak stress

Hardware-only gaps

The mock has parity with the real fwlibe64.dll for the calls OtOpcUa issues, but a real CNC can still surface things a reference implementation can't:

  1. Series-specific firmware quirks — alarm retention across power cycles, parameter range enforcement by the CNC (not the driver), MTB custom screens, series-specific option bits. Each series has documented behaviours that only a bench CNC exercises.
  2. Wire-level stress — burst reads, concurrent device writes, network-partition recovery under load. The mock handles these correctly but production behaviour is the source of truth.
  3. Transient operational states — alarm floods, emergency-stop transitions, power-on resync. These are easy to stub but hard to cover comprehensively in the mock.

Track the close-out under task #54 (live-CNC smoke). When the rig lands, the hardware path runs alongside the mock path; the mock stays as the CI quality gate.

When to trust each layer

Question Unit Integration (mock) Real CNC
"Does PMC address R100.3 route to the right bit?" ✅ ✅ ✅
"Does the Fanuc status → OPC UA StatusCode map cover every documented code?" ✅ (contract) ✅ ✅
"Does FocasDriver.ReadAsync correctly decode a seeded parameter?" no ✅ ✅
"Does IAlarmSource fire raise + clear events?" ✅ (Fake) ✅ (wire) ✅
"Does a real read against a 30i Series return correct bytes?" no ✅ (via profile) ✅ (required)
"Do series-specific firmware quirks behave as documented?" no no ✅ (required)
"Does the driver survive real network partitions?" no partial (socket kill) ✅ (required)

Running the integration fixture

# 1) Start the mock on a chosen profile.
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml up -d

# 2) Run the tests. No shim build, no DLL copy — the driver dials the mock directly.
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/

Or use scripts/integration/run-focas.ps1 which wraps compose up / test / compose down and accepts -Profile <name> to pin a per-series run.

Key fixture / config files

  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/ — vendored focas-mock Python source + Dockerfile
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml — per-series compose profiles
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs — collection fixture + mock admin API client
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs — fixed-tree end-to-end tests (identity / axes / spindle / program / timers)
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs — broader wire-backend coverage: PARAM / MACRO / PMC reads, DiscoverAsync, SubscribeAsync, IAlarmSource raise + clear, IHostConnectivityProbe
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs — in-process unit fake
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs — the managed wire client backing production deployments
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs — per-series range validator
  • docs/v2/focas-version-matrix.md — authoritative range reference