202 lines
7.9 KiB
C#
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();
|
|
}
|
|
}
|