Auto: focas-f4a — write infrastructure + per-tag opt-in

Closes #268
This commit is contained in:
Joseph Doherty
2026-04-26 04:32:43 -04:00
parent 6f1657b1c0
commit 1bfe8fba0e
13 changed files with 521 additions and 11 deletions

View File

@@ -20,7 +20,9 @@ public sealed class FocasCapabilityTests
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
Tags =
[
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
// Writable: true required after the F4-a default flip (issue #268) so the
// discovered Run tag still surfaces with SecurityClassification.Operate.
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },

View File

@@ -53,11 +53,18 @@ public sealed class FocasPmcBitRmwTests
{
var fake = new PmcRmwFake();
var factory = new FakeFocasClientFactory { Customise = () => fake };
// PMC bit RMW exercises the write path; opt every supplied tag into Writable + flip the
// driver-level Writes.Enabled gate so the tests still drive the wire path after F4-a's
// safer-by-default flip (issue #268).
var writableTags = tags
.Select(t => t with { Writable = true })
.ToArray();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Tags = writableTags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true },
}, "drv-1", factory);
return (drv, fake);
}

View File

@@ -16,6 +16,10 @@ public sealed class FocasReadWriteTests
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
// F4-a flipped Writable + Writes.Enabled defaults to false for safer-by-default
// posture (issue #268). The legacy read-write test fixture opts both back on so
// existing assertions exercise the same wire path the original tests covered.
Writes = new FocasWritesOptions { Enabled = true },
}, "drv-1", factory);
return (drv, factory);
}
@@ -170,7 +174,7 @@ public sealed class FocasReadWriteTests
public async Task Successful_write_logs_address_type_value()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
@@ -187,7 +191,7 @@ public sealed class FocasReadWriteTests
public async Task Write_status_code_maps_via_FocasStatusMapper()
{
var (drv, factory) = NewDriver(
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
@@ -210,10 +214,11 @@ public sealed class FocasReadWriteTests
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
Writes = new FocasWritesOptions { Enabled = true },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);

View File

@@ -0,0 +1,262 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Issue #268, plan PR F4-a — write infrastructure tests covering the driver-level
/// <c>Writes.Enabled</c> opt-in, the per-tag <c>Writable</c> default flip to false,
/// <c>WriteIdempotent</c> plumbing through Polly retry, and DTO/JSON config
/// round-tripping for the new <c>Writes</c> section.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWriteInfrastructureTests
{
private static FocasDriver NewDriver(
FocasWritesOptions writes,
FocasTagDefinition[] tags,
out FakeFocasClientFactory factory)
{
factory = new FakeFocasClientFactory();
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = writes,
}, "drv-1", factory);
}
[Fact]
public async Task DriverLevel_Writes_disabled_returns_BadNotWritable_even_when_per_tag_Writable_true()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = false },
tags:
[
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
],
out _);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public async Task DriverLevel_Writes_enabled_per_tag_Writable_false_returns_BadNotWritable()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true },
tags:
[
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false),
],
out _);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public async Task DriverLevel_Writes_enabled_per_tag_Writable_true_dispatches_to_wire_client()
{
// F4-a's wire dispatch surface is unchanged — when both flags are flipped, the call
// reaches the (fake) wire client, which by default returns Good. F4-b will introduce
// BadNotSupported branches for kinds the wire layer hasn't implemented yet.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true },
tags:
[
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
factory.Clients[0].WriteLog.Count.ShouldBe(1);
}
[Fact]
public async Task DriverLevel_Writes_disabled_short_circuits_before_touching_wire_client()
{
// Regression: the driver-level flag must reject before EnsureConnectedAsync, so a
// misconfigured wire client (no DLL, no IPC peer) doesn't fault when writes are off.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = false },
tags:
[
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
factory.Clients.Count.ShouldBe(0); // never even constructed a client
}
[Fact]
public void PerTag_Writable_default_is_false_post_F4a()
{
// Regression test for the flipped default — the safer-by-default posture means a
// newly-onboarded tag is read-only until the deployment explicitly opts in.
var def = new FocasTagDefinition(
Name: "X",
DeviceHostAddress: "focas://10.0.0.5:8193",
Address: "R100",
DataType: FocasDataType.Byte);
def.Writable.ShouldBeFalse();
def.WriteIdempotent.ShouldBeFalse();
}
[Fact]
public void FocasWritesOptions_default_Enabled_is_false()
{
// The driver-level master switch is the second of the two opt-ins required for any
// CNC write to flow. Default-off matches plan PR F4-a (issue #268).
new FocasWritesOptions().Enabled.ShouldBeFalse();
new FocasDriverOptions().Writes.Enabled.ShouldBeFalse();
}
[Fact]
public void Dto_round_trip_preserves_Writes_Enabled()
{
// JSON config -> FocasDriverOptions -> JSON; the Writes section must survive the
// bootstrapper's Deserialize step + the driver factory's options materialisation.
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Writes": { "Enabled": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
// The driver type is sealed; reach into the public options surface via reflection-free
// path — InitializeAsync would parse Tags, but here we just want to confirm the flag
// round-tripped. Use a known-tagless config + assert via a sentinel: a write call
// returns BadNodeIdUnknown rather than the BadNotWritable short-circuit, which proves
// the driver-level gate was opened by the JSON.
var task = drv.InitializeAsync("{}", CancellationToken.None);
task.IsCompleted.ShouldBeTrue();
// Issue a write at an unknown reference; if Writes.Enabled was false the driver
// would short-circuit every entry to BadNotWritable. Instead, with Writes.Enabled=true
// the per-entry tag-lookup runs and returns BadNodeIdUnknown for the unmapped name.
var results = drv.WriteAsync(
[new WriteRequest("Unknown", 0)], CancellationToken.None).GetAwaiter().GetResult();
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public void Dto_default_omitted_Writes_section_keeps_safer_default()
{
// A config with no Writes section at all should keep the safer-by-default off posture.
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }]
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
var results = drv.WriteAsync(
[new WriteRequest("Unknown", 0)], CancellationToken.None).GetAwaiter().GetResult();
// Off-by-default + no tag-lookup short-circuit means BadNotWritable, not BadNodeIdUnknown.
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public async Task CapabilityInvoker_honours_WriteIdempotent_for_Polly_retry()
{
// Plumb-through test: WriteIdempotent=false disables retry regardless of pipeline
// configuration; WriteIdempotent=true lets the Write capability's retry policy take
// effect. We exercise CapabilityInvoker.ExecuteWriteAsync directly because the
// server-layer dispatch (DriverNodeManager) is what actually reads the per-tag flag —
// the FOCAS driver itself just surfaces the WriteIdempotent value through
// FocasTagDefinition for the server to consume.
var builder = new DriverResiliencePipelineBuilder();
DriverResilienceOptions Options() => new()
{
Tier = DriverTier.A,
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Write] = new CapabilityPolicy(
TimeoutSeconds: 30, RetryCount: 3, BreakerFailureThreshold: 0),
},
};
var invoker = new CapabilityInvoker(builder, "drv-1", Options, "FOCAS");
var idempotentAttempts = 0;
await Should.ThrowAsync<InvalidOperationException>(async () =>
await invoker.ExecuteWriteAsync<int>(
hostName: "host-1",
isIdempotent: true,
callSite: _ =>
{
idempotentAttempts++;
throw new InvalidOperationException("boom");
},
cancellationToken: CancellationToken.None));
idempotentAttempts.ShouldBe(4); // 1 initial + 3 retries
var nonIdempotentAttempts = 0;
await Should.ThrowAsync<InvalidOperationException>(async () =>
await invoker.ExecuteWriteAsync<int>(
hostName: "host-1",
isIdempotent: false,
callSite: _ =>
{
nonIdempotentAttempts++;
throw new InvalidOperationException("boom");
},
cancellationToken: CancellationToken.None));
nonIdempotentAttempts.ShouldBe(1); // no retry — invoker swaps in a no-retry pipeline
}
[Fact]
public async Task Batch_with_writes_disabled_short_circuits_every_entry()
{
// The driver-level gate fires once and applies to every batch entry — useful when an
// operator submits a 50-entry write batch against a server with Writes.Enabled=false.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = false },
tags:
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: true),
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R102", FocasDataType.Byte, Writable: false),
],
out _);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", (sbyte)1),
new WriteRequest("B", (sbyte)2),
new WriteRequest("C", (sbyte)3),
new WriteRequest("Unknown", (sbyte)4),
], CancellationToken.None);
results.Count.ShouldBe(4);
foreach (var r in results)
r.StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
}