@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user