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; /// /// Issue #268, plan PR F4-a — write infrastructure tests covering the driver-level /// Writes.Enabled opt-in, the per-tag Writable default flip to false, /// WriteIdempotent plumbing through Polly retry, and DTO/JSON config /// round-tripping for the new Writes section. /// [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.Write] = new CapabilityPolicy( TimeoutSeconds: 30, RetryCount: 3, BreakerFailureThreshold: 0), }, }; var invoker = new CapabilityInvoker(builder, "drv-1", Options, "FOCAS"); var idempotentAttempts = 0; await Should.ThrowAsync(async () => await invoker.ExecuteWriteAsync( 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(async () => await invoker.ExecuteWriteAsync( 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); } }