Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWriteMacroTests.cs
2026-04-26 04:54:28 -04:00

202 lines
7.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Issue #269, plan PR F4-b — <c>cnc_wrmacro</c> coverage. The driver-level
/// <c>Writes.AllowMacro</c> kill switch sits on top of the F4-a
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins. MACRO tags
/// surface <see cref="SecurityClassification.Operate"/> (lighter ACL than PARAM)
/// because macro writes are the standard HMI-driven recipe / setpoint surface.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWriteMacroTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(
FocasWritesOptions writes,
FocasTagDefinition[] tags,
out FakeFocasClientFactory factory)
{
factory = new FakeFocasClientFactory();
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
Writes = writes,
}, "drv-1", factory);
}
[Fact]
public async Task AllowMacro_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowMacro = false, AllowParameter = true },
tags:
[
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("M500", 99)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
// Gate fires pre-EnsureConnectedAsync so no wire client gets constructed at all.
factory.Clients.ShouldBeEmpty();
}
[Fact]
public async Task AllowMacro_true_dispatches_to_typed_WriteMacroAsync()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowMacro = true },
tags:
[
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("M500", 99)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var log = factory.Clients[0].MacroWriteLog;
log.Count.ShouldBe(1);
log[0].addr.Kind.ShouldBe(FocasAreaKind.Macro);
log[0].addr.Number.ShouldBe(500);
log[0].value.ShouldBe(99);
// Generic write log untouched — MACRO routes through the typed entry point.
factory.Clients[0].WriteLog.ShouldBeEmpty();
}
[Fact]
public async Task MacroWrite_round_trip_stores_value_visible_to_subsequent_read()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowMacro = true },
tags:
[
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("M500", 42)], CancellationToken.None);
factory.Clients[0].Values["MACRO:500"].ShouldBe(42);
}
[Fact]
public void Tag_classification_MACRO_yields_Operate()
{
// Server-layer ACL key — MACRO: tags require WriteOperate (per the
// F4-b plan note about lighter authorization for HMI recipe writes).
var tag = new FocasTagDefinition(
"M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true);
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate);
}
[Fact]
public void FocasWritesOptions_default_AllowMacro_is_false()
{
new FocasWritesOptions().AllowMacro.ShouldBeFalse();
new FocasDriverOptions().Writes.AllowMacro.ShouldBeFalse();
}
[Fact]
public void Dto_round_trip_preserves_AllowMacro()
{
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Tags": [{
"Name": "M", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "MACRO:500", "DataType": "Int32", "Writable": true
}],
"Writes": { "Enabled": true, "AllowMacro": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
var results = drv.WriteAsync(
[new WriteRequest("M", 42)], CancellationToken.None).GetAwaiter().GetResult();
// unimplemented backend — anything except BadNotWritable proves the gate let
// the call through (likely BadCommunicationError from the no-op factory).
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public void Dto_round_trip_preserves_both_granular_flags()
{
// Both flags must round-trip independently — a deployment that opts into
// PARAM only doesn't accidentally let MACRO writes through, and vice versa.
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Writes": { "Enabled": true, "AllowParameter": true, "AllowMacro": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
// Both gates open: the driver no longer rejects either kind at the granular
// gate. Submit one of each and confirm neither maps to BadNotWritable
// (BadNodeIdUnknown when the tag isn't configured is fine — what matters is
// we stayed past the per-kind gate).
var results = drv.WriteAsync(
[
new WriteRequest("ParamUnknown", 0),
new WriteRequest("MacroUnknown", 0),
], CancellationToken.None).GetAwaiter().GetResult();
// Both writes hit BadNodeIdUnknown (tag-lookup fails) rather than the
// BadNotWritable short-circuit — confirms both flags reached the driver.
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Per_kind_gate_does_not_affect_PMC_writes()
{
// Defense in depth: AllowParameter / AllowMacro stay locked but PMC writes
// (which already worked in F4-a) keep flowing through Writes.Enabled +
// per-tag Writable. This guards against regressing the F4-a surface.
var drv = NewDriver(
writes: new FocasWritesOptions
{
Enabled = true,
AllowParameter = false,
AllowMacro = false,
},
tags:
[
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Byte, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("R100", (sbyte)1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
// PMC routes through the generic WriteAsync, not the typed entry points.
factory.Clients[0].WriteLog.Count.ShouldBe(1);
factory.Clients[0].ParameterWriteLog.ShouldBeEmpty();
factory.Clients[0].MacroWriteLog.ShouldBeEmpty();
}
}