264 lines
11 KiB
C#
264 lines
11 KiB
C#
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/F4-c add per-kind
|
|
// gates (AllowParameter / AllowMacro / AllowPmc); PMC byte writes route through the
|
|
// typed WritePmcRangeAsync entry point post-F4-c so we assert on PmcRangeWriteLog.
|
|
var drv = NewDriver(
|
|
writes: new FocasWritesOptions { Enabled = true, AllowPmc = 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].PmcRangeWriteLog.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);
|
|
}
|
|
}
|