FOCAS — retire Tier-C split, inline managed wire client, make read-only

Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.

Architecture

- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
  (owner-imported — see Wire/FocasWireClient.cs for the full surface).
  Opens two TCP sockets, runs the initiate handshake, serialises requests
  on socket 2 through a semaphore, closes cleanly with PDU + socket
  teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
  `IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
  alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
  against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
  and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
  rejected at startup with a diagnostic pointing at the migration doc.

Deletions

- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
  Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
  FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
  P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
  `IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
  Host-process supervisor (backoff, circuit breaker, heartbeat, post-
  mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
  FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
  SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
  exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
  DLL that masqueraded as Fwlib64.dll.

Solution changes

- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
  `Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
  hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
  entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
  `WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.

Integration tests

- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
  tree reads (identity, axes, dynamic, program, operation mode, timers,
  spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
  PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
  `IAlarmSource` raise/clear transitions, and `ProbeAsync` /
  `OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
  (Docker container with the vendored Python mock's native FOCAS/2
  Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
  compose down. Dropped the shim-build stage + DLL-copy step + the split
  testhost workaround (the latter only existed because of native-DLL
  lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
  service. Tests seed per-series state via `mock_load_profile` at test
  start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
  FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
  pre-refresh snapshot only spoke the JSON admin protocol.

Tests

- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
  63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
  surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
  to assert the new diagnostic message pointing at
  `docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.

Docs

- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
  deployment collapses to one `"Backend": "wire"` config block, no
  separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
  gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
  topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
  the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
  driver complement closed. FOCAS change-log entry added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-24 14:10:59 -04:00
parent 404b54add0
commit 4b0664bd55
105 changed files with 19530 additions and 4873 deletions

View File

@@ -1,200 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
{
/// <summary>
/// Validates that <see cref="FwlibFrameHandler"/> correctly dispatches each
/// <see cref="FocasMessageKind"/> to the corresponding <see cref="IFocasBackend"/>
/// method and serializes the response into the expected response kind. Uses
/// <see cref="FakeFocasBackend"/> so no hardware is needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FwlibFrameHandlerTests
{
private static async Task RoundTripAsync<TReq, TResp>(
IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
Action<TResp> assertResponse)
{
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(reqKind, MessagePackSerializer.Serialize(req), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(CancellationToken.None);
frame.HasValue.ShouldBeTrue();
frame!.Value.Kind.ShouldBe(expectedRespKind);
assertResponse(MessagePackSerializer.Deserialize<TResp>(frame.Value.Body));
}
private static FwlibFrameHandler BuildHandler() =>
new(new FakeFocasBackend(), new LoggerConfiguration().CreateLogger());
[Fact]
public async Task OpenSession_returns_a_new_session_id()
{
long sessionId = 0;
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
BuildHandler(),
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest { HostAddress = "h:8193" },
FocasMessageKind.OpenSessionResponse,
resp => { resp.Success.ShouldBeTrue(); resp.SessionId.ShouldBeGreaterThan(0L); sessionId = resp.SessionId; });
sessionId.ShouldBeGreaterThan(0L);
}
[Fact]
public async Task Read_without_open_session_returns_internal_error()
{
await RoundTripAsync<ReadRequest, ReadResponse>(
BuildHandler(),
FocasMessageKind.ReadRequest,
new ReadRequest
{
SessionId = 999,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
DataType = FocasDataTypeCode.Int32,
},
FocasMessageKind.ReadResponse,
resp => { resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("session-not-open"); });
}
[Fact]
public async Task Full_open_write_read_round_trip_preserves_value()
{
var handler = BuildHandler();
// Open.
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
var openResp = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body);
var sessionId = openResp.SessionId;
// Write 42 at MACRO:500 as Int32.
buffer.Position = 0;
buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.WriteRequest,
MessagePackSerializer.Serialize(new WriteRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Int32,
ValueTypeCode = FocasDataTypeCode.Int32,
ValueBytes = MessagePackSerializer.Serialize((int)42),
}), writer, CancellationToken.None);
// Read back.
buffer.Position = 0;
buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.ReadRequest,
MessagePackSerializer.Serialize(new ReadRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Int32,
}), writer, CancellationToken.None);
buffer.Position = 0;
var readFrame = await reader.ReadFrameAsync(CancellationToken.None);
readFrame.HasValue.ShouldBeTrue();
readFrame!.Value.Kind.ShouldBe(FocasMessageKind.ReadResponse);
// With buffer reuse there may be multiple queued frames; we want the last one.
var lastResp = MessagePackSerializer.Deserialize<ReadResponse>(readFrame.Value.Body);
// If the Write frame is first, drain it.
if (lastResp.ValueBytes is null)
{
var next = await reader.ReadFrameAsync(CancellationToken.None);
lastResp = MessagePackSerializer.Deserialize<ReadResponse>(next!.Value.Body);
}
lastResp.Success.ShouldBeTrue();
MessagePackSerializer.Deserialize<int>(lastResp.ValueBytes!).ShouldBe(42);
}
[Fact]
public async Task PmcBitWrite_sets_specified_bit()
{
var handler = BuildHandler();
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body).SessionId;
buffer.Position = 0; buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.PmcBitWriteRequest,
MessagePackSerializer.Serialize(new PmcBitWriteRequest
{
SessionId = sessionId,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
BitIndex = 3,
Value = true,
}), writer, CancellationToken.None);
buffer.Position = 0;
var resp = MessagePackSerializer.Deserialize<PmcBitWriteResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
}
[Fact]
public async Task Probe_reports_healthy_when_session_open()
{
var handler = BuildHandler();
using var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body).SessionId;
buffer.Position = 0; buffer.SetLength(0);
await handler.HandleAsync(FocasMessageKind.ProbeRequest,
MessagePackSerializer.Serialize(new ProbeRequest { SessionId = sessionId }), writer, CancellationToken.None);
buffer.Position = 0;
var resp = MessagePackSerializer.Deserialize<ProbeResponse>(
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
resp.Healthy.ShouldBeTrue();
}
[Fact]
public async Task Unconfigured_backend_returns_pointed_error_message()
{
var handler = new FwlibFrameHandler(new UnconfiguredFocasBackend(), new LoggerConfiguration().CreateLogger());
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
handler,
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest { HostAddress = "h:8193" },
FocasMessageKind.OpenSessionResponse,
resp =>
{
resp.Success.ShouldBeFalse();
resp.Error.ShouldContain("Fwlib32");
resp.ErrorCode.ShouldBe("NoFwlibBackend");
});
}
}
}

View File

@@ -1,157 +0,0 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
{
/// <summary>
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
/// the BuiltinAdministrators group.
/// </summary>
[Trait("Category", "Integration")]
public sealed class IpcHandshakeIntegrationTests
{
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
{
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await stream.ConnectAsync(5_000, ct);
var reader = new FrameReader(stream, leaveOpen: true);
var writer = new FrameWriter(stream, leaveOpen: true);
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
var ack = await reader.ReadFrameAsync(ct);
if (ack is null) throw new EndOfStreamException("no HelloAck");
if (ack.Value.Kind != FocasMessageKind.HelloAck)
throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind);
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
return (stream, reader, writer);
}
[Fact]
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
const string secret = "test-secret-2026";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
using (stream)
using (reader)
using (writer)
{
await writer.WriteAsync(FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = 42 }, cts.Token);
var hbAck = await reader.ReadFrameAsync(cts.Token);
hbAck.HasValue.ShouldBeTrue();
hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck);
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L);
}
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
[Fact]
public async Task Handshake_with_wrong_secret_is_rejected()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, "real-secret", log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
{
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
s.Dispose();
r.Dispose();
w.Dispose();
});
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
[Fact]
public async Task Stub_handler_returns_not_implemented_for_data_plane_request()
{
if (IsAdministrator()) return;
using var identity = WindowsIdentity.GetCurrent();
var sid = identity.User!;
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
const string secret = "stub-test";
Logger log = new LoggerConfiguration().CreateLogger();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var server = new PipeServer(pipe, sid, secret, log);
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
using (stream)
using (reader)
using (writer)
{
await writer.WriteAsync(FocasMessageKind.ReadRequest,
new ReadRequest
{
SessionId = 1,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
DataType = FocasDataTypeCode.Int32,
},
cts.Token);
var resp = await reader.ReadFrameAsync(cts.Token);
resp.HasValue.ShouldBeTrue();
resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse);
var err = MessagePackSerializer.Deserialize<ErrorResponse>(resp.Value.Body);
err.Code.ShouldBe("not-implemented");
err.Message.ShouldContain("PR C");
}
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
}
}

View File

@@ -1,86 +0,0 @@
using System;
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
{
[Trait("Category", "Unit")]
public sealed class PostMortemMmfTests : IDisposable
{
private readonly string _tempPath;
public PostMortemMmfTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin");
}
public void Dispose()
{
if (File.Exists(_tempPath)) File.Delete(_tempPath);
}
[Fact]
public void Write_and_read_preserve_order_and_content()
{
using (var mmf = new PostMortemMmf(_tempPath, capacity: 10))
{
mmf.Write(opKind: 1, "read R100");
mmf.Write(opKind: 2, "write MACRO:500 = 3.14");
mmf.Write(opKind: 3, "probe ok");
}
// Reopen (simulating a reader after the writer crashed).
using var reader = new PostMortemMmf(_tempPath, capacity: 10);
var entries = reader.ReadAll();
entries.Length.ShouldBe(3);
entries[0].OpKind.ShouldBe(1L);
entries[0].Message.ShouldBe("read R100");
entries[1].OpKind.ShouldBe(2L);
entries[2].Message.ShouldBe("probe ok");
}
[Fact]
public void Ring_buffer_wraps_at_capacity()
{
using var mmf = new PostMortemMmf(_tempPath, capacity: 3);
for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}");
var entries = mmf.ReadAll();
entries.Length.ShouldBe(3);
// Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order).
entries[0].Message.ShouldBe("op-7");
entries[1].Message.ShouldBe("op-8");
entries[2].Message.ShouldBe("op-9");
}
[Fact]
public void Truncated_message_is_null_terminated_and_does_not_overflow()
{
using var mmf = new PostMortemMmf(_tempPath, capacity: 4);
var big = new string('x', 500); // longer than the 240-byte message capacity
mmf.Write(42, big);
var entries = mmf.ReadAll();
entries.Length.ShouldBe(1);
entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240);
entries[0].OpKind.ShouldBe(42L);
}
[Fact]
public void Reopening_with_existing_data_preserves_entries()
{
using (var first = new PostMortemMmf(_tempPath, capacity: 5))
{
first.Write(1, "first-run-1");
first.Write(2, "first-run-2");
}
using var second = new PostMortemMmf(_tempPath, capacity: 5);
var entries = second.ReadAll();
entries.Length.ShouldBe(2);
entries[0].Message.ShouldBe("first-run-1");
}
}
}

View File

@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,94 @@
# FOCAS Docker simulator — focas-mock + shim DLL
Hardware-free FOCAS fixture for OtOpcUa's integration test matrix. Runs
the vendored [`focas-mock`](focas-mock/VENDORED.md) Python server under
Docker and pairs it with the [shim DLL](../Shim/VENDORED.md) that
masquerades as `Fwlib64.dll` inside the .NET test process.
## Architecture
```
┌────────────────────────────┐ cnc_allclibhndl3 / cnc_rdparam / ...
│ xunit test process │ (P/Invoke, __stdcall)
│ ├── Driver.FOCAS │
│ │ └── FwlibNative.cs ─┼─┐
│ └── FocasSimFixture │ │ resolves to...
└────────────────────────────┘ │
┌────────────────────────────┐
│ Fwlib64.dll (shim) │ JSON over TCP
│ tests/.../Shim/focas_ │──────────────────────┐
│ shim.c compiled here │ │
└────────────────────────────┘ │
┌─────────────────────────────┐
│ focas-mock (Docker) │
│ python:3.11-slim │
│ profile-aware responses │
│ mock_load_profile / │
│ mock_patch admin methods │
└─────────────────────────────┘
```
The shim bridges the binary ABI (C `__stdcall` exports with FOCAS struct
shapes) to the mock's newline-delimited JSON protocol. OtOpcUa's
`FocasSimFixture` seeds per-test state by sending `mock_load_profile` +
`mock_patch` admin calls on the same socket. Tests assert the managed
driver sees the seeded values through its normal P/Invoke path.
## Running
Pick one compose profile (they all publish 8193 — only one at a time):
```powershell
docker compose -f Docker/docker-compose.yml --profile thirtyone up -d
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests
docker compose -f Docker/docker-compose.yml --profile thirtyone down
```
Available profiles + their focas-mock target:
| compose --profile | focas-mock profile | Covers |
|---|---|---|
| `thirtyone` / `thirty` / `thirtytwo` | `fwlib30i64` | 30i / 31i / 32i series |
| `sixteen` | `FWLIB64` | 16i / 18i / 21i legacy family |
| `zerod` / `zerof` / `zeromf` / `zerotf` | `fwlib0iD64` | 0i-D / 0i-F / 0i-MF / 0i-TF |
| `powermotion` | `fwlib0DN64` | Power Motion i |
| `ethernet` | `fwlibe64` | Ethernet-variant DLL |
| `ncguide` | `fwlibNCG64` | NC Guide PC simulator |
## What this covers — and what it doesn't
**Covered:**
- All 10 FOCAS functions `FwlibNative.cs` P/Invokes
- Read-after-write round-trip for parameters, macros, PMC ranges
- PMC bit read-modify-write path (via the `pmc_wrpmcrng` seam)
- `IAlarmSource` raise + clear transitions (via `mock_schedule_alarms`)
- Per-series profile selection — tests can pin one and assert series-gated
behaviour
**Not covered** (still hardware-gated):
- Real FOCAS2 TCP wire protocol (this is a JSON mock; the shim hides
the real protocol entirely)
- CNC-specific firmware quirks (position scaling across power cycles,
edit-mode session locks, MTB custom screens)
- Concurrent-read behaviour on the real `Fwlib64.dll` — the shim is
single-threaded per connection
See [`docs/drivers/FOCAS-Test-Fixture.md`](../../../docs/drivers/FOCAS-Test-Fixture.md)
for the full coverage map.
## Skip behaviour
`FocasSimFixture` probes the mock at collection init time:
- Mock unreachable → tests skip with the compose-up command to run
- Mock reachable but shim DLL not loaded → tests skip with a pointer
at `Shim/build.ps1`
- Both available → tests run
This lets the same test assembly be green on a fresh CI box without
docker, green on a dev box with just the docker compose up, and
exercise the full wire path when the shim is built.

View File

@@ -0,0 +1,34 @@
# FOCAS simulator — focas-mock JSON/TCP + native FOCAS2 Ethernet server.
#
# The image is built from the vendored focas-mock snapshot at ./focas-mock/
# (see focas-mock/VENDORED.md for refresh procedure).
#
# Usage:
# docker compose -f Docker/docker-compose.yml up -d --wait
# docker compose -f Docker/docker-compose.yml down
#
# One service, one container — the mock's native FOCAS Ethernet responder
# auto-detects the binary PDU prefix (`a0 a0 a0 a0`) on the same TCP port
# that serves JSON admin commands. Tests that need per-series behaviour
# call `mock_load_profile` via the fixture's admin API at test start.
# The pre-wire-client era had one compose profile per CNC series; that
# ceremony is gone because the managed wire client doesn't depend on a
# per-series shim DLL.
services:
focas-sim:
image: otopcua-focas-sim:latest
build:
context: ./focas-mock
dockerfile: Dockerfile
container_name: otopcua-focas-sim
ports:
- "8193:8193"
restart: "no"
command: ["--profile", "FWLIB64"]
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('127.0.0.1',8193),timeout=2); s.close()\" || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s

View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN pip install --no-cache-dir .
EXPOSE 8193
ENTRYPOINT ["focas-mock", "serve", "--host", "0.0.0.0", "--port", "8193"]
CMD ["--profile", "FWLIB64"]

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,191 @@
# focas-mock
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
The project is built from two inputs:
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
- The vendor `fwlib.cs` interop file, used as the callable surface reference
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, exposes a JSON-over-TCP mock API, and implements the targeted native FOCAS Ethernet wire protocol used by OtOpcUa fixed-tree tests.
## What is included
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
- A profile extractor that inspects PE exports with `pefile`
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
- Built-in profiles for:
- `FWLIB64`
- `fwlib0DN64`
- `fwlib0iD64`
- `fwlib30i64`
- `fwlibe64`
- `fwlibNCG64`
- A stateful mock server with:
- version/profile switching
- forced error injection
- runtime state patching
- built-in default mock data
- auto-detected native FOCAS Ethernet PDU handling for the targeted API subset
## Quick start
Install in editable mode:
```powershell
python -m pip install -e .
```
List the generated profiles:
```powershell
focas-mock list-profiles
```
Start the mock server with the 30i profile:
```powershell
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
```
Start with a JSON patch file that overrides the default data:
```powershell
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
```
## Protocol
The server accepts two protocols on the same port:
- newline-delimited JSON for fixture control and shim tests
- native FOCAS Ethernet binary PDUs from the real `fwlibe64.dll`
JSON requests are one object per line:
```json
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
```
Example response:
```json
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
```
Supported admin methods:
- `mock_get_state`
- `mock_patch`
- `mock_reset`
- `mock_load_profile`
- `mock_list_methods`
- `mock_schedule_alarms`
Example patch request:
```json
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
```
Native FOCAS Ethernet clients do not use the JSON request format. Seed profile
and fixture state with JSON first, then point `cnc_allclibhndl3` at the same
host and port. Wire-level details are documented in
`docs/FOCAS_WIRE_PROTOCOL.md`.
For clients that should avoid FANUC DLL loading entirely, `dotnet/Focas.Wire`
contains a native C# read-only TCP client for the verified wire subset. It does
not expose write APIs; use the JSON control channel to preset fixture state.
Example test setup over TCP:
```json
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
```
## Regenerating profiles
The built-in JSON profiles are generated from the vendored binaries:
```powershell
python -m focas_mock.cli extract-profiles
```
By default this reads:
- `vendor/fanuc-cnc-api/64bit/*.dll`
- `upstream/fwlib.cs`
and writes:
- `src/focas_mock/builtin_profiles/*.json`
## Testing Direct P/Invoke Clients
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
```powershell
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
.\shim\build.ps1
$env:FOCAS_MOCK_HOST = "127.0.0.1"
$env:FOCAS_MOCK_PORT = "8193"
```
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
Native Ethernet wire notes are in `docs/FOCAS_WIRE_PROTOCOL.md`.
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
## Implemented mock calls
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
- `cnc_allclibhndl`
- `cnc_allclibhndl2`
- `cnc_allclibhndl3`
- `cnc_freelibhndl`
- `cnc_sysinfo`
- `cnc_statinfo`
- `cnc_rddynamic2`
- `cnc_actf`
- `cnc_acts`
- `cnc_acts2`
- `cnc_getpath`
- `cnc_setpath`
- `cnc_rdaxisname`
- `cnc_rdspdlname`
- `cnc_rdparam`
- `cnc_wrparam`
- `cnc_rdmacro`
- `cnc_wrmacro`
- `cnc_rdalmmsg2`
- `pmc_rdpmcrng`
- `pmc_wrpmcrng`
- `cnc_rdopmsg`
- `cnc_rdopmode`
- `cnc_rdprgnum`
- `cnc_exeprgname2`
- `cnc_rdexecprog`
- `cnc_rdseqnum`
- `cnc_rdblkcount`
- `cnc_rdproginfo`
- `cnc_rdprogdir3`
- `cnc_rdtimer`
- `cnc_rdspmeter`
- `cnc_rdsvmeter`
- `cnc_rdspload`
- `cnc_rdspgear`
- `cnc_rdspmaxrpm`
- `cnc_rddiagnum`
- `cnc_rddiaginfo`
- `cnc_diagnoss`
## Limitations
- This is not a binary-compatible replacement for FANUC's DLLs.
- Native FOCAS Ethernet support is intentionally scoped to the targeted API subset documented in `docs/FOCAS_WIRE_PROTOCOL.md`.
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.

View File

@@ -0,0 +1,45 @@
# focas-mock — vendored snapshot
Source: `C:\Users\dohertj2\Desktop\focas` (sibling project in this dev environment).
**Snapshot date:** 2026-04-24 (second refresh — pulled the native FOCAS2 Ethernet responder work in).
## Why vendored
OtOpcUa's FOCAS integration fixture runs against the Python mock server.
The upstream lives in its own repo; this directory is a verbatim
snapshot so CI can build the Docker image without network access to the
source repo and so OtOpcUa's test matrix pins a known-good revision.
The managed `WireFocasClient` speaks the mock's native FOCAS2 Ethernet
binary protocol directly — there's no longer a companion shim DLL.
## What's here
| Path | Purpose |
|------|---------|
| `src/focas_mock/` | Python package — TCP JSON/line-delimited mock server with 6 Fanuc CNC profiles |
| `pyproject.toml` | Package metadata; installs `focas-mock` CLI |
| `Dockerfile` | `python:3.11-slim` image built by the parent `docker-compose.yml` |
| `README.md` | Upstream README |
| `LICENSE` | MIT — permissive, vendoring allowed |
## Refreshing the snapshot
When upstream ships changes worth pulling:
```powershell
$src = "C:\Users\dohertj2\Desktop\focas"
$dest = "$PWD"
Remove-Item -Recurse -Force "$dest\src" 2>$null
Copy-Item -Recurse "$src\src" "$dest\src"
Copy-Item "$src\pyproject.toml" "$dest\"
Copy-Item "$src\README.md" "$dest\"
Copy-Item "$src\LICENSE" "$dest\"
Copy-Item "$src\Dockerfile" "$dest\"
```
Update the snapshot date at the top of this file afterward. No other
files belong here — the Docker build context is just the Python package
and its metadata.

View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "focas-mock"
version = "0.1.0"
description = "Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = ["pefile>=2024.8.26"]
[project.scripts]
focas-mock = "focas_mock.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
focas_mock = ["builtin_profiles/*.json"]

View File

@@ -0,0 +1,185 @@
Metadata-Version: 2.4
Name: focas-mock
Version: 0.1.0
Summary: Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports.
License-Expression: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pefile>=2024.8.26
Dynamic: license-file
# focas-mock
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
The project is built from two inputs:
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
- The vendor `fwlib.cs` interop file, used as the callable surface reference
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, and exposes a JSON-over-TCP mock API whose method names match common FOCAS entry points such as `cnc_allclibhndl3`, `cnc_sysinfo`, `cnc_statinfo`, and `cnc_rddynamic2`.
## What is included
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
- A profile extractor that inspects PE exports with `pefile`
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
- Built-in profiles for:
- `FWLIB64`
- `fwlib0DN64`
- `fwlib0iD64`
- `fwlib30i64`
- `fwlibe64`
- `fwlibNCG64`
- A stateful mock server with:
- version/profile switching
- forced error injection
- runtime state patching
- built-in default mock data
## Quick start
Install in editable mode:
```powershell
python -m pip install -e .
```
List the generated profiles:
```powershell
focas-mock list-profiles
```
Start the mock server with the 30i profile:
```powershell
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
```
Start with a JSON patch file that overrides the default data:
```powershell
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
```
## Protocol
The server speaks newline-delimited JSON. Each request is one JSON object per line:
```json
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
```
Example response:
```json
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
```
Supported admin methods:
- `mock_get_state`
- `mock_patch`
- `mock_reset`
- `mock_load_profile`
- `mock_list_methods`
Example patch request:
```json
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
```
Example test setup over TCP:
```json
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
```
## Regenerating profiles
The built-in JSON profiles are generated from the vendored binaries:
```powershell
python -m focas_mock.cli extract-profiles
```
By default this reads:
- `vendor/fanuc-cnc-api/64bit/*.dll`
- `upstream/fwlib.cs`
and writes:
- `src/focas_mock/builtin_profiles/*.json`
## Testing Direct P/Invoke Clients
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
```powershell
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
.\shim\build.ps1
$env:FOCAS_MOCK_HOST = "127.0.0.1"
$env:FOCAS_MOCK_PORT = "8193"
```
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
## Implemented mock calls
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
- `cnc_allclibhndl`
- `cnc_allclibhndl2`
- `cnc_allclibhndl3`
- `cnc_freelibhndl`
- `cnc_sysinfo`
- `cnc_statinfo`
- `cnc_rddynamic2`
- `cnc_actf`
- `cnc_acts`
- `cnc_acts2`
- `cnc_getpath`
- `cnc_setpath`
- `cnc_rdaxisname`
- `cnc_rdspdlname`
- `cnc_rdparam`
- `cnc_wrparam`
- `cnc_rdmacro`
- `cnc_wrmacro`
- `cnc_rdalmmsg2`
- `pmc_rdpmcrng`
- `pmc_wrpmcrng`
- `cnc_rdopmsg`
- `cnc_rdopmode`
- `cnc_rdprgnum`
- `cnc_exeprgname2`
- `cnc_rdexecprog`
- `cnc_rdseqnum`
- `cnc_rdblkcount`
- `cnc_rdproginfo`
- `cnc_rdprogdir3`
- `cnc_rdtimer`
- `cnc_rdspmeter`
- `cnc_rdsvmeter`
- `cnc_rdspload`
- `cnc_rdspgear`
- `cnc_rdspmaxrpm`
- `cnc_rddiagnum`
- `cnc_rddiaginfo`
- `cnc_diagnoss`
## Limitations
- This is not a binary-compatible replacement for FANUC's DLLs.
- This is not a reverse-engineered implementation of FANUC's wire protocol.
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.

View File

@@ -0,0 +1,25 @@
LICENSE
README.md
pyproject.toml
src/focas_mock/__init__.py
src/focas_mock/cli.py
src/focas_mock/constants.py
src/focas_mock/data_store.py
src/focas_mock/defaults.py
src/focas_mock/export_introspection.py
src/focas_mock/profiles.py
src/focas_mock/server.py
src/focas_mock.egg-info/PKG-INFO
src/focas_mock.egg-info/SOURCES.txt
src/focas_mock.egg-info/dependency_links.txt
src/focas_mock.egg-info/entry_points.txt
src/focas_mock.egg-info/requires.txt
src/focas_mock.egg-info/top_level.txt
src/focas_mock/builtin_profiles/FWLIB64.json
src/focas_mock/builtin_profiles/fwlib0DN64.json
src/focas_mock/builtin_profiles/fwlib0iD64.json
src/focas_mock/builtin_profiles/fwlib30i64.json
src/focas_mock/builtin_profiles/fwlibNCG64.json
src/focas_mock/builtin_profiles/fwlibe64.json
tests/test_profiles.py
tests/test_server.py

View File

@@ -0,0 +1,2 @@
[console_scripts]
focas-mock = focas_mock.cli:main

View File

@@ -0,0 +1,5 @@
from .profiles import list_profiles, load_profile
from .server import FocasMockServer
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from .data_store import MockDataStore
from .export_introspection import write_profiles
from .profiles import list_profiles, load_profile
from .server import FocasMockServer
def _default_root() -> Path:
return Path(__file__).resolve().parents[2]
def build_parser() -> argparse.ArgumentParser:
root = _default_root()
parser = argparse.ArgumentParser(prog="focas-mock")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="Start the mock server.")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8193)
serve.add_argument("--profile", default="fwlib30i64")
serve.add_argument("--data", help="Optional JSON patch file.")
sub.add_parser("list-profiles", help="List built-in profiles.")
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
dump_profile.add_argument("profile")
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
return parser
async def _run_server(args: argparse.Namespace) -> None:
profile = load_profile(args.profile)
store = MockDataStore(profile)
if args.data:
store.load_patch_file(args.data)
server = FocasMockServer(args.host, args.port, profile, store)
await server.start()
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
try:
await server.serve_forever()
finally:
await server.close()
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "list-profiles":
for profile in list_profiles():
print(profile)
return
if args.command == "dump-profile":
print(json.dumps(load_profile(args.profile), indent=2))
return
if args.command == "extract-profiles":
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
for path in written:
print(path)
return
if args.command == "serve":
asyncio.run(_run_server(args))
return
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
EW_PROTOCOL = -17
EW_SOCKET = -16
EW_NODLL = -15
EW_BUS = -11
EW_SYSTEM2 = -10
EW_HSSB = -9
EW_HANDLE = -8
EW_VERSION = -7
EW_UNEXP = -6
EW_SYSTEM = -5
EW_PARITY = -4
EW_MMCSYS = -3
EW_RESET = -2
EW_BUSY = -1
EW_OK = 0
EW_FUNC = 1
EW_LENGTH = 2
EW_NUMBER = 3
EW_ATTRIB = 4
EW_DATA = 5
EW_NOOPT = 6
EW_PROT = 7
EW_OVRFLOW = 8
EW_PARAM = 9
EW_BUFFER = 10
EW_PATH = 11
EW_MODE = 12
EW_REJECT = 13
EW_DTSRVR = 14
EW_ALARM = 15
EW_STOP = 16
EW_PASSWD = 17
RC_LABELS = {
EW_PROTOCOL: "EW_PROTOCOL",
EW_SOCKET: "EW_SOCKET",
EW_NODLL: "EW_NODLL",
EW_BUS: "EW_BUS",
EW_SYSTEM2: "EW_SYSTEM2",
EW_HSSB: "EW_HSSB",
EW_HANDLE: "EW_HANDLE",
EW_VERSION: "EW_VERSION",
EW_UNEXP: "EW_UNEXP",
EW_SYSTEM: "EW_SYSTEM",
EW_PARITY: "EW_PARITY",
EW_MMCSYS: "EW_MMCSYS",
EW_RESET: "EW_RESET",
EW_BUSY: "EW_BUSY",
EW_OK: "EW_OK",
EW_FUNC: "EW_FUNC",
EW_LENGTH: "EW_LENGTH",
EW_NUMBER: "EW_NUMBER",
EW_ATTRIB: "EW_ATTRIB",
EW_DATA: "EW_DATA",
EW_NOOPT: "EW_NOOPT",
EW_PROT: "EW_PROT",
EW_OVRFLOW: "EW_OVRFLOW",
EW_PARAM: "EW_PARAM",
EW_BUFFER: "EW_BUFFER",
EW_PATH: "EW_PATH",
EW_MODE: "EW_MODE",
EW_REJECT: "EW_REJECT",
EW_DTSRVR: "EW_DTSRVR",
EW_ALARM: "EW_ALARM",
EW_STOP: "EW_STOP",
EW_PASSWD: "EW_PASSWD",
}
IMPLEMENTED_FOCAS_METHODS = [
"cnc_allclibhndl",
"cnc_allclibhndl2",
"cnc_allclibhndl3",
"cnc_freelibhndl",
"cnc_sysinfo",
"cnc_statinfo",
"cnc_rddynamic2",
"cnc_actf",
"cnc_acts",
"cnc_acts2",
"cnc_getpath",
"cnc_setpath",
"cnc_rdaxisname",
"cnc_rdspdlname",
"cnc_rdparam",
"cnc_wrparam",
"cnc_rdmacro",
"cnc_wrmacro",
"cnc_rdalmmsg2",
"pmc_rdpmcrng",
"pmc_wrpmcrng",
"cnc_rdopmsg",
"cnc_rdopmode",
"cnc_rdprgnum",
"cnc_exeprgname2",
"cnc_rdexecprog",
"cnc_rdseqnum",
"cnc_rdblkcount",
"cnc_rdproginfo",
"cnc_rdprogdir3",
"cnc_rdtimer",
"cnc_rdspmeter",
"cnc_rdsvmeter",
"cnc_rdspload",
"cnc_rdspgear",
"cnc_rdspmaxrpm",
"cnc_rddiagnum",
"cnc_rddiaginfo",
"cnc_diagnoss",
]
ADMIN_METHODS = [
"mock_get_state",
"mock_patch",
"mock_reset",
"mock_load_profile",
"mock_list_methods",
"mock_schedule_alarms",
]

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, Mapping
from .defaults import make_default_state
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
for key, value in patch.items():
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
_deep_merge(target[key], value)
else:
target[key] = deepcopy(value)
return target
class MockDataStore:
def __init__(self, profile: Mapping[str, Any]) -> None:
self.profile = dict(profile)
self._defaults = make_default_state(profile)
self._state = deepcopy(self._defaults)
@property
def state(self) -> dict[str, Any]:
return self._state
def snapshot(self) -> dict[str, Any]:
return deepcopy(self._state)
def reset(self) -> dict[str, Any]:
self._state = deepcopy(self._defaults)
return self.snapshot()
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
_deep_merge(self._state, patch)
return self.snapshot()
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
patch = json.loads(Path(path).read_text(encoding="utf-8"))
return self.merge_patch(patch)
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
entry = self._state.get("forced_errors", {}).get(method)
if not entry:
return None
if isinstance(entry, int):
return entry, f"forced error for {method}"
rc = int(entry.get("rc", 0))
count = int(entry.get("count", 1))
message = str(entry.get("message", f"forced error for {method}"))
if count <= 1:
self._state["forced_errors"].pop(method, None)
else:
entry["count"] = count - 1
return rc, message

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from typing import Any, Mapping
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
lowered = profile_name.lower()
if "30i" in lowered or "ncg" in lowered:
return ("30i", 32, 2, 4)
if "0id" in lowered or "0dn" in lowered:
return ("0i-D", 24, 1, 2)
if "fwlibe64" in lowered:
return ("e64", 8, 1, 2)
return ("generic", 8, 1, 2)
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
profile_name = str(profile.get("profile_name", "FWLIB64"))
family, default_axes, max_path, max_spindles = _family_config(profile_name)
max_axis = int(profile.get("max_axis_hint") or default_axes)
axis_names = [
"X",
"Y",
"Z",
"A",
"B",
"C",
"U",
"V",
"W",
"P",
"Q",
"R",
]
while len(axis_names) < max_axis:
axis_names.append(f"A{len(axis_names) + 1}")
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
programs = [
{"number": 1, "comment": "MAIN", "length": 128},
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
]
return {
"sysinfo": {
"addinfo": 0,
"max_axis": max_axis,
"cnc_type": family[:2].ljust(2),
"mt_type": "M ",
"series": family[:4].ljust(4),
"version": "A1.0",
"axes": str(max_axis).rjust(2, "0"),
},
"statinfo": {
"aut": 1,
"run": 3,
"motion": 1,
"mstb": 0,
"emergency": 0,
"alarm": 0,
"edit": 0,
},
"paths": {"current": 1, "max": max_path},
"dynamic": {
"alarm": 0,
"prgnum": 1,
"prgmnum": 1,
"seqnum": 120,
"actf": 1500,
"acts": 3200,
"axes": {
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
},
},
"actf": 1500,
"acts": 3200,
"acts2": [3200] + [0] * (max_spindles - 1),
"axis_names": axis_names[:max_axis],
"spindle_names": spindle_names,
"parameters": {
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
},
"pmc": {
"R": {
"100": {"type": "byte", "value": 0},
"101": {"type": "byte", "value": 1},
"102": {"type": "byte", "value": 0},
},
},
"macros": {
"100": {"value": 12345, "decimal": 3},
"101": {"value": 98765, "decimal": 3},
},
"alarms": [
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
],
"operator_messages": [
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
],
"program": {
"current": 1,
"main": 1,
"sequence": 120,
"block_count": 42,
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
"directory": programs,
},
"spindle": {
"meter": [
{"name": spindle_names[0], "value": 56, "unit": "%"},
{"name": spindle_names[1], "value": 0, "unit": "%"},
],
"servo_meter": [
{"name": axis_names[0], "value": 14, "unit": "%"},
{"name": axis_names[1], "value": 8, "unit": "%"},
{"name": axis_names[2], "value": 5, "unit": "%"},
],
"load": [
{"name": spindle_names[0], "load": 56, "speed": 3200},
{"name": spindle_names[1], "load": 0, "speed": 0},
],
"gear": [1] * max_spindles,
"max_rpm": [6000] * max_spindles,
},
"timers": {
"power_on": 86400,
"operating": 7200,
"cutting": 3600,
"cycle": 95,
},
"operation_mode": {"mode": 1, "name": "MEM"},
"diagnostics": {
"300": {"type": "long", "value": 14, "description": "servo load X"},
"301": {"type": "long", "value": 8, "description": "servo load Y"},
"302": {"type": "long", "value": 5, "description": "servo load Z"},
},
"forced_errors": {},
}

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from .constants import IMPLEMENTED_FOCAS_METHODS
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
return sorted(set(imports))
def extract_dll_exports(dll_path: str | Path) -> list[str]:
import pefile
pe = pefile.PE(str(dll_path))
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
return sorted(set(exports))
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
lowered = dll_name.lower()
if lowered == "fwlib64.dll":
return ("generic-ethernet", 8, 1)
if lowered == "fwlibe64.dll":
return ("embedded-ethernet", 8, 1)
if "30i" in lowered:
return ("30i/31i/32i", 32, 2)
if "ncg" in lowered:
return ("ncguide-family", 32, 2)
if "0id" in lowered:
return ("0i-d-family", 24, 1)
if "0dn" in lowered:
return ("0-dn-family", 24, 1)
return ("unknown", 8, 1)
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
dll_path = Path(dll_path)
exports = extract_dll_exports(dll_path)
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
export_set = set(exports)
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
wrapper_supported = sorted(wrapper_imports & export_set)
return {
"profile_name": dll_path.stem,
"dll_name": dll_path.name,
"series_hint": series_hint,
"max_axis_hint": max_axis_hint,
"max_path_hint": max_path_hint,
"export_count": len(exports),
"connection_methods": connection_methods,
"mock_methods": mock_methods,
"wrapper_supported_count": len(wrapper_supported),
"wrapper_supported_methods": wrapper_supported,
"exports": exports,
"notes": [
"Exports extracted directly from the 64-bit DLL PE export directory.",
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
],
}
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
dll_dir = Path(dll_dir)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for dll_path in sorted(dll_dir.glob("*.dll")):
profile = build_profile(dll_path, fwlib_cs_path)
out_path = out_dir / f"{dll_path.stem}.json"
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
written.append(out_path)
return written

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
PROFILE_ALIASES = {
"ZeroI_D": "fwlib0iD64",
"ZeroI_F": "fwlib0iD64",
"ZeroI_MF": "fwlib0iD64",
"ZeroI_TF": "fwlib0iD64",
"Sixteen_i": "FWLIB64",
"Thirty_i": "fwlib30i64",
"ThirtyOne_i": "fwlib30i64",
"ThirtyTwo_i": "fwlib30i64",
"PowerMotion_i": "fwlib0DN64",
}
def list_profiles() -> list[str]:
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
def resolve_profile_name(profile_name: str) -> str:
return PROFILE_ALIASES.get(profile_name, profile_name)
def load_profile(profile_name: str) -> dict[str, Any]:
profile_name = resolve_profile_name(profile_name)
candidates = [
PROFILE_DIR / f"{profile_name}.json",
PROFILE_DIR / f"{Path(profile_name).stem}.json",
]
for candidate in candidates:
if candidate.exists():
return json.loads(candidate.read_text(encoding="utf-8"))
available = ", ".join(list_profiles())
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")

View File

@@ -0,0 +1,192 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests;
/// <summary>
/// Fixture for the focas-mock simulator. Probes the Docker mock at
/// collection init; if reachable, exposes helpers that drive the mock's
/// admin surface (<c>mock_load_profile</c>, <c>mock_patch</c>,
/// <c>mock_reset</c>, <c>mock_schedule_alarms</c>) so tests can seed
/// deterministic state before exercising the managed driver.
/// </summary>
/// <remarks>
/// Single skip gate: <see cref="SkipReason"/> is non-null when the
/// <c>localhost:8193</c> TCP probe fails. Tests call
/// <c>Assert.Skip</c>.
/// </remarks>
public sealed class FocasSimFixture : IAsyncDisposable
{
private const string EndpointEnvVar = "OTOPCUA_FOCAS_SIM_ENDPOINT";
private const string ProfileEnvVar = "OTOPCUA_FOCAS_SIM_PROFILE";
private const string DefaultHost = "localhost";
private const int DefaultPort = 8193;
public string Host { get; }
public int Port { get; }
/// <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; }
/// <summary>When the <see cref="ExpectedProfile"/> maps to a concrete
/// <see cref="FocasCncSeries"/>, this is it. Null otherwise.</summary>
public FocasCncSeries? ExpectedSeries { get; }
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
public string? SkipReason { get; }
public FocasSimFixture()
{
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
(Host, Port) = ParseEndpoint(endpoint);
ExpectedProfile = Environment.GetEnvironmentVariable(ProfileEnvVar);
ExpectedSeries = ParseSeries(ExpectedProfile);
try
{
using var client = new TcpClient(AddressFamily.InterNetwork);
var addresses = System.Net.Dns.GetHostAddresses(Host);
var ip = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback;
var task = client.ConnectAsync(ip, Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"focas-mock at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (`docker compose -f Docker/docker-compose.yml up -d`) " +
$"or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"focas-mock at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it or override {EndpointEnvVar}.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// ---- Admin API helpers ----
/// <summary>
/// Load a focas-mock profile. Accepts either the raw DLL-stem name
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
/// </summary>
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
SendAdminAsync("mock_patch", new { state }, ct);
/// <summary>Reset the mock to the selected profile's default state.</summary>
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
SendAdminAsync("mock_reset", new { }, ct);
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
/// how the shim talks to the mock; simpler than pooling.</summary>
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
{
using var client = new TcpClient();
await client.ConnectAsync(Host, Port, ct).ConfigureAwait(false);
using var stream = client.GetStream();
var request = JsonSerializer.SerializeToUtf8Bytes(new
{
id = Interlocked.Increment(ref _nextId),
method,
@params,
});
await stream.WriteAsync(request, ct).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
var buffer = new byte[65536];
var len = 0;
while (len < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(len), ct).ConfigureAwait(false);
if (read == 0) break;
len += read;
// focas-mock replies with a single newline-terminated JSON object.
if (Array.IndexOf(buffer, (byte)'\n', 0, len) >= 0) break;
}
var newline = Array.IndexOf(buffer, (byte)'\n', 0, len);
var jsonLen = newline >= 0 ? newline : len;
var text = Encoding.UTF8.GetString(buffer, 0, jsonLen);
using var doc = JsonDocument.Parse(text);
var rc = doc.RootElement.GetProperty("rc").GetInt32();
if (rc != 0)
{
var message = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "?";
throw new InvalidOperationException($"focas-mock {method} returned rc={rc} ({message}).");
}
// Return the "result" subtree cloned — document is disposed on exit.
return doc.RootElement.GetProperty("result").Clone();
}
private static int _nextId;
// ---- Parsing ----
private static (string Host, int Port) ParseEndpoint(string endpoint)
{
const string focasScheme = "focas://";
var body = endpoint.StartsWith(focasScheme, StringComparison.OrdinalIgnoreCase)
? endpoint[focasScheme.Length..]
: endpoint;
var slash = body.IndexOf('/');
if (slash >= 0) body = body[..slash];
var colon = body.LastIndexOf(':');
if (colon < 0) return (body, DefaultPort);
var host = body[..colon];
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, DefaultPort);
}
/// <summary>
/// Map either a focas-mock DLL-stem profile (<c>fwlib30i64</c>) or a
/// OtOpcUa-style alias (<c>ThirtyOne_i</c>) to the matching
/// <see cref="FocasCncSeries"/>. Keeps tests able to assert
/// series-gated behaviour regardless of how the profile was pinned.
/// </summary>
private static FocasCncSeries? ParseSeries(string? profile)
{
if (string.IsNullOrWhiteSpace(profile)) return null;
var trimmed = profile.Trim();
// Try the OtOpcUa alias set first — it's a superset of human-readable names.
// The docker-compose profile names (thirtyone / zerod / ...) are accepted too so
// run-focas.ps1's -Profile argument threads straight through.
var aliasMapped = trimmed switch
{
"ThirtyOne_i" or "Thirty_i" or "ThirtyTwo_i"
or "thirtyone_i" or "thirty_i" or "thirtytwo_i"
or "thirtyone" or "thirty" or "thirtytwo"
or "fwlib30i64" => "ThirtyOne_i",
"Sixteen_i" or "sixteen_i" or "sixteen" or "FWLIB64" => "Sixteen_i",
"Zero_i_D" or "Zero_i_F" or "Zero_i_MF" or "Zero_i_TF"
or "zero_i_d" or "zero_i_f" or "zero_i_mf" or "zero_i_tf"
or "zerod" or "zerof" or "zeromf" or "zerotf"
or "fwlib0iD64" => "Zero_i_D",
"PowerMotion_i" or "powermotion_i" or "powermotion"
or "fwlib0DN64" => "PowerMotion_i",
_ => null,
};
return aliasMapped is not null && Enum.TryParse<FocasCncSeries>(aliasMapped, out var parsed)
? parsed : null;
}
}
[Xunit.CollectionDefinition(Name)]
public sealed class FocasSimCollection : Xunit.ICollectionFixture<FocasSimFixture>
{
public const string Name = "FocasSim";
}

View File

@@ -0,0 +1,263 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// End-to-end coverage for the driver capabilities that aren't part of
/// the fixed-tree path: user-authored <c>PARAM:</c> / <c>MACRO:</c> / PMC
/// reads, <c>DiscoverAsync</c> emission, <c>SubscribeAsync</c> +
/// <c>OnDataChange</c>, <c>IAlarmSource</c> raise/clear, and
/// <c>IHostConnectivityProbe</c> transitions. All via the managed
/// <see cref="WireFocasClient"/> against the running focas-mock.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendCoverageTests
{
private readonly FocasSimFixture _fx;
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
[Fact]
public async Task User_tag_reads_route_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
parameters = new Dictionary<string, object>
{
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
},
macros = new Dictionary<string, object>
{
["500"] = new { value = 42000, @decimal = 3 },
},
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 7 },
}},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags =
[
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("R100", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-usertags", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["Param6711", "Macro500", "R100"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snaps[0].Value).ShouldBe(1234);
Convert.ToDouble(snaps[1].Value).ShouldBe(42.0, tolerance: 0.001);
Convert.ToInt32(snaps[2].Value).ShouldBe(7);
}
}
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, DeviceName: "Lathe-1")],
Tags =
[
new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("Speed", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-discover", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, ct);
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
builder.Folders.ShouldContain(f => f.BrowseName == DeviceHost && f.DisplayName == "Lathe-1");
builder.Variables.ShouldContain(v => v.BrowseName == "Run");
builder.Variables.ShouldContain(v => v.BrowseName == "Speed");
}
}
[Fact]
public async Task Subscribe_fires_OnDataChange_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 1 },
}},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false)],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-subscribe", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Run"], TimeSpan.FromMilliseconds(150), ct);
await WaitFor(() => events.Count >= 1, TimeSpan.FromSeconds(3));
Convert.ToInt32(events.First().Snapshot.Value).ShouldBe(1);
// Flip the PMC byte — next poll tick should emit a fresh OnDataChange.
var before = events.Count;
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 99 },
}},
}, ct);
await WaitFor(() => events.Any(e => Convert.ToInt32(e.Snapshot.Value) == 99),
TimeSpan.FromSeconds(3));
await drv.UnsubscribeAsync(handle, ct);
events.Count.ShouldBeGreaterThan(before);
}
}
[Fact]
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
// Start with no active alarms.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var sub = await drv.SubscribeAlarmsAsync([], ct);
// Raise one alarm.
await _fx.PatchStateAsync(new
{
alarms = new[]
{
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
},
}, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
// Clear.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
await drv.UnsubscribeAlarmsAsync(sub, ct);
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
events.ShouldContain(e => e.Message.Contains("cleared"));
events[0].SourceNodeId.ShouldBe(DeviceHost);
}
}
[Fact]
public async Task Probe_transitions_to_Running_against_live_mock()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Probe = new FocasProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
},
}, driverInstanceId: "wire-probe", clientFactory: new WireFocasClientFactory());
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(5));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,280 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// Dual-run companion to <see cref="FixedTreePopulatesTests"/> — exercises the same
/// fixed-tree scenarios through the pure-managed <see cref="WireFocasClient"/>
/// instead of the shim/P-Invoke path. Proves both backends observe identical
/// state against the same focas-mock instance.
/// </summary>
/// <remarks>
/// Scheduled for removal in Wire migration phase 3 (task #104) once the shim is
/// deleted — at that point only this class survives and becomes the canonical
/// fixed-tree integration test.
/// </remarks>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendTests
{
private readonly FocasSimFixture _fx;
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
[Fact]
public async Task Identity_axes_and_dynamic_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "3 ",
},
axis_names = new[] { "X", "Y", "Z" },
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 42,
actf = 1500, acts = 3200,
pos = new { absolute = 123456, machine = 123450, relative = 6, distance = 0 },
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
},
}, driverInstanceId: "focas-wire-identity", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
var state = driver.GetDeviceState(DeviceHost);
state.ShouldNotBeNull();
state.FixedTreeCache.ShouldNotBeNull();
state.FixedTreeCache.SysInfo.Series.ShouldStartWith("30i");
state.FixedTreeCache.Axes.Count.ShouldBe(3);
state.FixedTreeCache.Axes[0].Display.ShouldBe("X");
await WaitFor(() =>
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
TimeSpan.FromSeconds(3));
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
}
}
[Fact]
public async Task Program_and_operation_mode_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "1 ",
},
axis_names = new[] { "X" },
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 42, prgmnum = 42, seqnum = 100,
actf = 0, acts = 0,
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
},
program = new
{
current = 42, main = 42, sequence = 100, block_count = 17,
executing_path = "O0042.NC",
},
operation_mode = new { mode = 3 },
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "focas-wire-program", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Program/Name",
$"{DeviceHost}/Program/ONumber",
$"{DeviceHost}/Program/BlockCount",
$"{DeviceHost}/OperationMode/Mode"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
snapshots[0].Value!.ToString().ShouldStartWith("O0042");
Convert.ToInt32(snapshots[1].Value).ShouldBe(42);
Convert.ToInt32(snapshots[2].Value).ShouldBe(17);
Convert.ToInt32(snapshots[3].Value).ShouldBe(3);
}
}
[Fact]
public async Task Timers_populate_via_wire_backend()
{
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" },
timers = new
{
power_on = 3600,
operating = 7200,
cutting = 1800,
cycle = 120,
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
TimerPollInterval = TimeSpan.FromMilliseconds(200),
ProgramPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "focas-wire-timers", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state is not null && state.LastTimers.Count == 4;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Timers/PowerOnSeconds",
$"{DeviceHost}/Timers/OperatingSeconds",
$"{DeviceHost}/Timers/CuttingSeconds",
$"{DeviceHost}/Timers/CycleSeconds"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToDouble(snapshots[0].Value).ShouldBe(3600.0, tolerance: 1);
Convert.ToDouble(snapshots[1].Value).ShouldBe(7200.0, tolerance: 1);
Convert.ToDouble(snapshots[2].Value).ShouldBe(1800.0, tolerance: 1);
Convert.ToDouble(snapshots[3].Value).ShouldBe(120.0, tolerance: 1);
}
}
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
{
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" },
spindle_names = new[] { "S1", "S2" },
spindle = new
{
load = new object[]
{
new { name = "S1", load = 56, speed = 3200 },
new { name = "S2", load = 12, speed = 1800 },
},
max_rpm = new[] { 6000, 4500 },
},
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1,
actf = 0, acts = 0,
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "focas-wire-spindle", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
&& state.LastSpindleLoads.Count >= 2;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Spindle/S1/Load",
$"{DeviceHost}/Spindle/S1/MaxRpm",
$"{DeviceHost}/Spindle/S2/Load",
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
}

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -20,12 +20,14 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
<!-- Docker/ (the Python simulator + profiles) is part of the project so the
file tree stays discoverable, but it doesn't need to be copied to bin/;
tests run it via docker compose, not via the test-output dir. -->
<None Include="Docker\**\*" Pack="false" CopyToOutputDirectory="Never"/>
</ItemGroup>
</Project>

View File

@@ -1,280 +0,0 @@
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
/// <summary>
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ContractRoundTripTests
{
private static T RoundTrip<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value);
return MessagePackSerializer.Deserialize<T>(bytes);
}
[Fact]
public void Hello_round_trips()
{
var original = new Hello
{
ProtocolMajor = 1,
ProtocolMinor = 2,
PeerName = "OtOpcUa.Server",
SharedSecret = "abc-123",
Features = ["bulk-read", "pmc-rmw"],
};
var decoded = RoundTrip(original);
decoded.ProtocolMajor.ShouldBe(1);
decoded.ProtocolMinor.ShouldBe(2);
decoded.PeerName.ShouldBe("OtOpcUa.Server");
decoded.SharedSecret.ShouldBe("abc-123");
decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
}
[Fact]
public void HelloAck_rejected_carries_reason()
{
var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
var decoded = RoundTrip(original);
decoded.Accepted.ShouldBeFalse();
decoded.RejectReason.ShouldBe("bad secret");
}
[Fact]
public void Heartbeat_and_ack_preserve_ticks()
{
var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
hb.MonotonicTicks.ShouldBe(987654321);
var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
ack.MonotonicTicks.ShouldBe(987654321);
ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void ErrorResponse_preserves_code_and_message()
{
var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
decoded.Code.ShouldBe("Fwlib32Crashed");
decoded.Message.ShouldBe("EW_UNEXPECTED");
}
[Fact]
public void OpenSessionRequest_preserves_series_and_timeout()
{
var decoded = RoundTrip(new OpenSessionRequest
{
HostAddress = "192.168.1.50:8193",
TimeoutMs = 3500,
CncSeries = 5,
});
decoded.HostAddress.ShouldBe("192.168.1.50:8193");
decoded.TimeoutMs.ShouldBe(3500);
decoded.CncSeries.ShouldBe(5);
}
[Fact]
public void OpenSessionResponse_failure_carries_error_code()
{
var decoded = RoundTrip(new OpenSessionResponse
{
Success = false,
SessionId = 0,
Error = "unreachable",
ErrorCode = "EW_SOCKET",
});
decoded.Success.ShouldBeFalse();
decoded.Error.ShouldBe("unreachable");
decoded.ErrorCode.ShouldBe("EW_SOCKET");
}
[Fact]
public void FocasAddressDto_carries_pmc_with_bit_index()
{
var decoded = RoundTrip(new FocasAddressDto
{
Kind = 0,
PmcLetter = "R",
Number = 100,
BitIndex = 3,
});
decoded.Kind.ShouldBe(0);
decoded.PmcLetter.ShouldBe("R");
decoded.Number.ShouldBe(100);
decoded.BitIndex.ShouldBe(3);
}
[Fact]
public void FocasAddressDto_macro_omits_letter_and_bit()
{
var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
decoded.Kind.ShouldBe(2);
decoded.PmcLetter.ShouldBeNull();
decoded.Number.ShouldBe(500);
decoded.BitIndex.ShouldBeNull();
}
[Fact]
public void ReadRequest_and_response_round_trip()
{
var req = RoundTrip(new ReadRequest
{
SessionId = 42,
Address = new FocasAddressDto { Kind = 1, Number = 1815 },
DataType = FocasDataTypeCode.Int32,
TimeoutMs = 1500,
});
req.SessionId.ShouldBe(42);
req.Address.Number.ShouldBe(1815);
req.DataType.ShouldBe(FocasDataTypeCode.Int32);
var resp = RoundTrip(new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize((int)12345),
ValueTypeCode = FocasDataTypeCode.Int32,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
});
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
MessagePackSerializer.Deserialize<int>(resp.ValueBytes!).ShouldBe(12345);
resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
}
[Fact]
public void WriteRequest_and_response_round_trip()
{
var req = RoundTrip(new WriteRequest
{
SessionId = 1,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Float64,
ValueBytes = MessagePackSerializer.Serialize(3.14159),
ValueTypeCode = FocasDataTypeCode.Float64,
});
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14159);
var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
}
[Fact]
public void PmcBitWriteRequest_preserves_bit_and_value()
{
var req = RoundTrip(new PmcBitWriteRequest
{
SessionId = 7,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
BitIndex = 5,
Value = true,
});
req.BitIndex.ShouldBe(5);
req.Value.ShouldBeTrue();
}
[Fact]
public void SubscribeRequest_round_trips_multiple_items()
{
var original = new SubscribeRequest
{
SessionId = 1,
SubscriptionId = 100,
IntervalMs = 250,
Items =
[
new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
],
};
var decoded = RoundTrip(original);
decoded.Items.Length.ShouldBe(2);
decoded.Items[0].MonitoredItemId.ShouldBe(1);
decoded.Items[0].Address.PmcLetter.ShouldBe("R");
decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
}
[Fact]
public void SubscribeResponse_rejected_items_survive()
{
var decoded = RoundTrip(new SubscribeResponse
{
Success = true,
RejectedMonitoredItemIds = [2, 7],
});
decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
}
[Fact]
public void UnsubscribeRequest_round_trips()
{
var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
decoded.SubscriptionId.ShouldBe(42);
}
[Fact]
public void OnDataChangeNotification_round_trips()
{
var original = new OnDataChangeNotification
{
SubscriptionId = 100,
Changes =
[
new()
{
MonitoredItemId = 1,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize(true),
ValueTypeCode = FocasDataTypeCode.Bit,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
},
],
};
var decoded = RoundTrip(original);
decoded.Changes.Length.ShouldBe(1);
MessagePackSerializer.Deserialize<bool>(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
}
[Fact]
public void ProbeRequest_and_response_round_trip()
{
var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
req.TimeoutMs.ShouldBe(500);
var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
resp.Healthy.ShouldBeTrue();
resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void RuntimeStatusChangeNotification_round_trips()
{
var decoded = RoundTrip(new RuntimeStatusChangeNotification
{
SessionId = 5,
RuntimeStatus = "Stopped",
ObservedAtUtcUnixMs = 1_700_000_000_000,
});
decoded.RuntimeStatus.ShouldBe("Stopped");
}
[Fact]
public void RecycleHostRequest_and_response_round_trip()
{
var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
req.Kind.ShouldBe("Hard");
req.Reason.ShouldBe("wedge-detected");
var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
resp.Accepted.ShouldBeTrue();
resp.GraceSeconds.ShouldBe(20);
}
}

View File

@@ -1,107 +0,0 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class FramingTests
{
[Fact]
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
}
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
hello.PeerName.ShouldBe("proxy");
hello.SharedSecret.ShouldBe("s3cr3t");
}
[Fact]
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
{
using var empty = new MemoryStream();
using var reader = new FrameReader(empty, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldBeNull();
}
[Fact]
public async Task FrameReader_throws_on_oversized_length_prefix()
{
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
using var stream = new MemoryStream(hostile);
using var reader = new FrameReader(stream, leaveOpen: true);
await Should.ThrowAsync<InvalidDataException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameReader_throws_on_mid_frame_eof()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
TestContext.Current.CancellationToken);
}
// Truncate so body is incomplete.
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
using var partial = new MemoryStream(truncated);
using var reader = new FrameReader(partial, leaveOpen: true);
await Should.ThrowAsync<EndOfStreamException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameWriter_serializes_concurrent_writes()
{
var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = i },
TestContext.Current.CancellationToken)).ToArray();
await Task.WhenAll(tasks);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var seen = new List<long>();
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
{
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
}
seen.Count.ShouldBe(20);
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
}
[Fact]
public void MessageKind_values_are_stable()
{
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
}
}

View File

@@ -48,6 +48,52 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
public List<FocasActiveAlarm> Alarms { get; } = [];
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
// ---- Fixed-tree T1 ----
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
return Task.FromResult(snap);
}
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
{
if (!Timers.TryGetValue(kind, out var t))
t = new FocasTimer(kind, 0, 0);
return Task.FromResult(t);
}
public List<FocasServoLoad> ServoLoads { get; } = [];
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
public List<int> SpindleLoads { get; } = [];
public List<int> SpindleMaxRpms { get; } = [];
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
public virtual void Dispose()
{
DisposeCount++;

View File

@@ -1,162 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Task #220 — covers the DriverConfig JSON contract that
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
/// or CNC required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDriverFactoryExtensionsTests
{
[Fact]
public void Register_adds_FOCAS_entry_to_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("FOCAS").ShouldNotBeNull();
}
[Fact]
public void Register_is_case_insensitive_via_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("focas").ShouldNotBeNull();
registry.TryGet("Focas").ShouldNotBeNull();
}
[Fact]
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
{
const string json = """
{
"Backend": "ipc",
"PipeName": "OtOpcUaFocasHost",
"SharedSecret": "secret-for-test",
"ConnectTimeoutMs": 5000,
"Series": "Thirty_i",
"TimeoutMs": 3000,
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
],
"Tags": [
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100", "DataType": "Int32", "Writable": true }
]
}
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
driver.ShouldNotBeNull();
driver.DriverInstanceId.ShouldBe("focas-0");
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
{
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
const string json = """
{ "PipeName": "p", "SharedSecret": "s" }
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_ipc_backend_missing_PipeName_throws()
{
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
.Message.ShouldContain("PipeName");
}
[Fact]
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
{
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
.Message.ShouldContain("SharedSecret");
}
[Fact]
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
{
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
// natively on first use.
const string json = """{ "Backend": "fwlib" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
driver.DriverInstanceId.ShouldBe("focas-fwlib");
}
[Fact]
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
{
// Useful for staging DriverInstance rows in the config DB before the Host is
// actually deployed — the server boots but reads/writes surface clear errors.
const string json = """{ "Backend": "unimplemented" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
driver.DriverInstanceId.ShouldBe("focas-unimpl");
}
[Fact]
public void CreateInstance_unknown_backend_throws_with_expected_list()
{
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
.Message.ShouldContain("gibberish");
}
[Fact]
public void CreateInstance_rejects_unknown_Series()
{
const string json = """
{ "Backend": "fwlib", "Series": "NotARealSeries" }
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
.Message.ShouldContain("NotARealSeries");
}
[Fact]
public void CreateInstance_rejects_tag_with_missing_DataType()
{
const string json = """
{
"Backend": "fwlib",
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
}
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
.Message.ShouldContain("DataType");
}
[Fact]
public void CreateInstance_null_or_whitespace_args_rejected()
{
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
}
[Fact]
public void Register_twice_throws()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.Register(registry));
}
}

View File

@@ -219,11 +219,11 @@ public sealed class FocasScaffoldingTests
// ---- UnimplementedFocasClientFactory ----
[Fact]
public void Default_factory_throws_on_Create_with_deployment_pointer()
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
{
var factory = new UnimplementedFocasClientFactory();
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
ex.Message.ShouldContain("Fwlib32.dll");
ex.Message.ShouldContain("licensed");
ex.Message.ShouldContain("wire");
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
}
}

View File

@@ -1,106 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Tests for the managed helpers inside FwlibNative + FwlibFocasClient that don't require the
/// licensed Fwlib32.dll — letter→ADR_* mapping, FocasDataType→data-type mapping, byte encoding.
/// The actual P/Invoke calls can only run where the DLL is present; field testing covers those.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FwlibNativeHelperTests
{
[Theory]
[InlineData("G", 0)]
[InlineData("F", 1)]
[InlineData("Y", 2)]
[InlineData("X", 3)]
[InlineData("A", 4)]
[InlineData("R", 5)]
[InlineData("T", 6)]
[InlineData("K", 7)]
[InlineData("C", 8)]
[InlineData("D", 9)]
[InlineData("E", 10)]
[InlineData("g", 0)] // case-insensitive
public void PmcAddrType_maps_every_valid_letter(string letter, short expected)
{
FocasPmcAddrType.FromLetter(letter).ShouldBe(expected);
}
[Theory]
[InlineData("Z")]
[InlineData("")]
[InlineData("XX")]
public void PmcAddrType_rejects_unknown_letters(string letter)
{
FocasPmcAddrType.FromLetter(letter).ShouldBeNull();
}
[Theory]
[InlineData(FocasDataType.Bit, 0)] // byte
[InlineData(FocasDataType.Byte, 0)]
[InlineData(FocasDataType.Int16, 1)] // word
[InlineData(FocasDataType.Int32, 2)] // long
[InlineData(FocasDataType.Float32, 4)]
[InlineData(FocasDataType.Float64, 5)]
public void PmcDataType_maps_FocasDataType_to_FOCAS_code(FocasDataType input, short expected)
{
FocasPmcDataType.FromFocasDataType(input).ShouldBe(expected);
}
[Fact]
public void EncodePmcValue_Byte_writes_signed_byte_at_offset_0()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Byte, (sbyte)-5, bitIndex: null);
((sbyte)buf[0]).ShouldBe((sbyte)-5);
}
[Fact]
public void EncodePmcValue_Int16_writes_little_endian()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int16, (short)0x1234, bitIndex: null);
buf[0].ShouldBe((byte)0x34);
buf[1].ShouldBe((byte)0x12);
}
[Fact]
public void EncodePmcValue_Int32_writes_little_endian()
{
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Int32, 0x12345678, bitIndex: null);
buf[0].ShouldBe((byte)0x78);
buf[1].ShouldBe((byte)0x56);
buf[2].ShouldBe((byte)0x34);
buf[3].ShouldBe((byte)0x12);
}
[Fact]
public void EncodePmcValue_Bit_without_bit_index_writes_byte_boolean()
{
// Task #181 closed the Bit-write gap — PMC Bit with a bitIndex now routes through
// WritePmcBitAsync's RMW path upstream, and raw EncodePmcValue only gets the
// no-bit-index case (treated as a whole-byte boolean).
var buf = new byte[40];
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, true, bitIndex: null);
buf[0].ShouldBe((byte)1);
FwlibFocasClient.EncodePmcValue(buf, FocasDataType.Bit, false, bitIndex: null);
buf[0].ShouldBe((byte)0);
}
[Fact]
public void EncodeParamValue_Int32_writes_little_endian()
{
var buf = new byte[32];
FwlibFocasClient.EncodeParamValue(buf, FocasDataType.Int32, 0x0A0B0C0D);
buf[0].ShouldBe((byte)0x0D);
buf[1].ShouldBe((byte)0x0C);
buf[2].ShouldBe((byte)0x0B);
buf[3].ShouldBe((byte)0x0A);
}
}

View File

@@ -1,265 +0,0 @@
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// End-to-end IPC round-trips over an in-memory loopback: <c>IpcFocasClient</c> talks
/// to a test fake that plays the Host's role by reading frames, dispatching on kind,
/// and responding with canned DTOs. Validates that every <see cref="IFocasClient"/>
/// method translates to the right wire frame + decodes the response correctly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class IpcFocasClientTests
{
private const string Secret = "test-secret";
private static async Task ServerLoopAsync(Stream serverSide, Func<FocasMessageKind, byte[], FrameWriter, Task> dispatch, CancellationToken ct)
{
using var reader = new FrameReader(serverSide, leaveOpen: true);
using var writer = new FrameWriter(serverSide, leaveOpen: true);
// Hello handshake.
var first = await reader.ReadFrameAsync(ct);
if (first is null) return;
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
var accepted = hello.SharedSecret == Secret;
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct);
if (!accepted) return;
while (!ct.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(ct);
if (frame is null) return;
await dispatch(frame.Value.Kind, frame.Value.Body, writer);
}
}
[Fact]
public async Task Connect_sends_OpenSessionRequest_and_caches_session_id()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
OpenSessionRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
if (kind == FocasMessageKind.OpenSessionRequest)
{
received = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token);
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i);
await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token);
client.IsConnected.ShouldBeTrue();
received.ShouldNotBeNull();
received!.HostAddress.ShouldBe("192.168.1.50:8193");
received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Connect_throws_when_host_rejects()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
if (kind == FocasMessageKind.OpenSessionRequest)
{
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token);
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token));
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Read_sends_ReadRequest_and_decodes_response()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
ReadRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.ReadRequest:
received = MessagePackSerializer.Deserialize<ReadRequest>(body);
await writer.WriteAsync(FocasMessageKind.ReadResponse,
new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize((int)12345),
ValueTypeCode = FocasDataTypeCode.Int32,
}, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null);
var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token);
status.ShouldBe(0u);
value.ShouldBe(12345);
received!.Address.Number.ShouldBe(1815);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Write_sends_WriteRequest_and_returns_status()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.WriteRequest:
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14);
await writer.WriteAsync(FocasMessageKind.WriteResponse,
new WriteResponse { Success = true, StatusCode = 0 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null),
FocasDataType.Float64, 3.14, cts.Token);
status.ShouldBe(0u);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Write_pmc_bit_sends_first_class_RMW_frame()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
PmcBitWriteRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.PmcBitWriteRequest:
received = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse,
new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5);
var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token);
status.ShouldBe(0u);
received.ShouldNotBeNull();
received!.BitIndex.ShouldBe(5);
received.Value.ShouldBeTrue();
received.Address.PmcLetter.ShouldBe("R");
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Probe_round_trips_health_from_host()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.ProbeRequest:
await writer.WriteAsync(FocasMessageKind.ProbeResponse,
new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
(await client.ProbeAsync(cts.Token)).ShouldBeTrue();
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Error_response_from_host_surfaces_as_FocasIpcException()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token);
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
var ex = await Should.ThrowAsync<FocasIpcException>(async () =>
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token));
ex.Code.ShouldBe("backend-exception");
cts.Cancel();
try { await server; } catch { }
}
}

View File

@@ -1,72 +0,0 @@
using System.IO.Pipelines;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Bidirectional in-memory stream pair for IPC tests. Two <c>System.IO.Pipelines.Pipe</c>
/// instances — one per direction — exposed as <see cref="System.IO.Stream"/> endpoints
/// via <c>PipeReader.AsStream</c> / <c>PipeWriter.AsStream</c>. Lets the test set up a
/// <c>FocasIpcClient</c> on one end and a minimal fake server loop on the other without
/// standing up a real named pipe.
/// </summary>
internal sealed class IpcLoopback : IAsyncDisposable
{
public Stream ClientSide { get; }
public Stream ServerSide { get; }
public IpcLoopback()
{
var clientToServer = new Pipe();
var serverToClient = new Pipe();
ClientSide = new DuplexPipeStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream());
ServerSide = new DuplexPipeStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
}
public async ValueTask DisposeAsync()
{
await ClientSide.DisposeAsync();
await ServerSide.DisposeAsync();
}
private sealed class DuplexPipeStream(Stream read, Stream write) : Stream
{
public override bool CanRead => true;
public override bool CanWrite => true;
public override bool CanSeek => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) => read.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
read.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
read.ReadAsync(buffer, ct);
public override void Write(byte[] buffer, int offset, int count) => write.Write(buffer, offset, count);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
write.WriteAsync(buffer, offset, count, ct);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
write.WriteAsync(buffer, ct);
public override void Flush() => write.Flush();
public override Task FlushAsync(CancellationToken ct) => write.FlushAsync(ct);
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
read.Dispose();
write.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -1,84 +0,0 @@
using System.IO.MemoryMappedFiles;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// The Proxy-side <see cref="PostMortemReader"/> must read the Host's MMF format
/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics
/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two
/// codebases in lockstep on the wire format without needing to reference the net48
/// Host assembly from the net10 test project.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PostMortemReaderCompatibilityTests : IDisposable
{
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin");
public void Dispose()
{
if (File.Exists(_tempPath)) File.Delete(_tempPath);
}
[Fact]
public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order()
{
const int magic = 0x4F465043;
const int capacity = 5;
const int headerBytes = 16;
const int entryBytes = 256;
const int messageOffset = 16;
var fileBytes = headerBytes + capacity * entryBytes;
using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read))
{
fs.SetLength(fileBytes);
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
acc.Write(0, magic);
acc.Write(4, 1);
acc.Write(8, capacity);
acc.Write(12, 2); // writeIndex — next write would land at slot 2
void WriteEntry(int slot, long ts, long op, string msg)
{
var offset = headerBytes + slot * entryBytes;
acc.Write(offset + 0, ts);
acc.Write(offset + 8, op);
var bytes = Encoding.UTF8.GetBytes(msg);
acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length);
acc.Write(offset + messageOffset + bytes.Length, (byte)0);
}
WriteEntry(0, 100, 1, "op-a");
WriteEntry(1, 200, 2, "op-b");
// Slots 2,3 unwritten (ts=0) — reader must skip.
WriteEntry(4, 50, 9, "old-wrapped");
}
var entries = new PostMortemReader(_tempPath).ReadAll();
entries.Length.ShouldBe(3);
// writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1.
// Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b".
entries[0].Message.ShouldBe("old-wrapped");
entries[1].Message.ShouldBe("op-a");
entries[2].Message.ShouldBe("op-b");
}
[Fact]
public void Reader_returns_empty_when_file_missing()
{
new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty();
}
[Fact]
public void Reader_returns_empty_when_magic_mismatches()
{
File.WriteAllBytes(_tempPath, new byte[1024]);
new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty();
}
}

View File

@@ -1,249 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class BackoffTests
{
[Fact]
public void Default_sequence_is_5s_15s_60s_then_clamped()
{
var b = new Backoff();
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
}
[Fact]
public void RecordStableRun_resets_the_ladder_to_the_start()
{
var b = new Backoff();
b.Next(); b.Next();
b.AttemptIndex.ShouldBe(2);
b.RecordStableRun();
b.AttemptIndex.ShouldBe(0);
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
}
}
[Trait("Category", "Unit")]
public sealed class CircuitBreakerTests
{
[Fact]
public void Allows_crashes_below_threshold()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
b.TryRecordCrash(now, out _).ShouldBeTrue();
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
b.StickyAlertActive.ShouldBeFalse();
}
[Fact]
public void Opens_when_exceeding_threshold_in_window()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
b.TryRecordCrash(now, out _);
b.TryRecordCrash(now.AddSeconds(1), out _);
b.TryRecordCrash(now.AddSeconds(2), out _);
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
cooldown.ShouldBe(TimeSpan.FromHours(1));
b.StickyAlertActive.ShouldBeTrue();
}
[Fact]
public void Escalates_cooldown_after_second_open()
{
var b = new CircuitBreaker();
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
// First burst — 4 crashes opens breaker with 1h cooldown.
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
b.StickyAlertActive.ShouldBeTrue();
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
b.TryRecordCrash(t1, out _);
b.TryRecordCrash(t1.AddSeconds(1), out _);
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
cooldown.ShouldBe(TimeSpan.FromHours(4));
}
[Fact]
public void ManualReset_clears_everything()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
b.StickyAlertActive.ShouldBeTrue();
b.ManualReset();
b.StickyAlertActive.ShouldBeFalse();
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
}
}
[Trait("Category", "Unit")]
public sealed class HeartbeatMonitorTests
{
[Fact]
public void Three_consecutive_misses_declares_dead()
{
var m = new HeartbeatMonitor();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeTrue();
}
[Fact]
public void Ack_resets_the_miss_counter()
{
var m = new HeartbeatMonitor();
m.RecordMiss(); m.RecordMiss();
m.ConsecutiveMisses.ShouldBe(2);
m.RecordAck(DateTime.UtcNow);
m.ConsecutiveMisses.ShouldBe(0);
}
}
[Trait("Category", "Unit")]
public sealed class FocasHostSupervisorTests
{
private sealed class FakeLauncher : IHostProcessLauncher
{
public int LaunchAttempts { get; private set; }
public int Terminations { get; private set; }
public Queue<Func<IFocasClient>> Plan { get; } = new();
public bool IsProcessAlive { get; set; }
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
{
LaunchAttempts++;
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
var next = Plan.Dequeue()();
IsProcessAlive = true;
return Task.FromResult(next);
}
public Task TerminateAsync(CancellationToken ct)
{
Terminations++;
IsProcessAlive = false;
return Task.CompletedTask;
}
}
private sealed class StubFocasClient : IFocasClient
{
public bool IsConnected => true;
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
Task.FromResult<(object?, uint)>((0, 0));
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
public void Dispose() { }
}
[Fact]
public async Task GetOrLaunch_returns_client_on_first_success()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
client.ShouldNotBeNull();
launcher.LaunchAttempts.ShouldBe(1);
}
[Fact]
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
launcher.Plan.Enqueue(() => new StubFocasClient());
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
var supervisor = new FocasHostSupervisor(launcher, backoff);
var unavailableMessages = new List<string>();
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
client.ShouldNotBeNull();
launcher.LaunchAttempts.ShouldBe(2);
unavailableMessages.Count.ShouldBe(1);
unavailableMessages[0].ShouldContain("launch-failed");
}
[Fact]
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
{
var launcher = new FakeLauncher();
for (var i = 0; i < 10; i++)
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
var supervisor = new FocasHostSupervisor(
launcher,
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
ex.Message.ShouldContain("circuit breaker");
supervisor.StickyAlertActive.ShouldBeTrue();
}
[Fact]
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
var messages = new List<string>();
supervisor.OnUnavailable += m => messages.Add(m);
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
launcher.Terminations.ShouldBe(1);
messages.ShouldContain("heartbeat-loss");
supervisor.ObservedCrashes.ShouldBe(1);
}
[Fact]
public async Task AcknowledgeAndReset_clears_sticky_alert()
{
var launcher = new FakeLauncher();
for (var i = 0; i < 10; i++)
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
var supervisor = new FocasHostSupervisor(
launcher,
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
supervisor.StickyAlertActive.ShouldBeTrue();
supervisor.AcknowledgeAndReset();
supervisor.StickyAlertActive.ShouldBeFalse();
}
[Fact]
public async Task Dispose_terminates_host_process()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
supervisor.Dispose();
launcher.Terminations.ShouldBe(1);
}
}