Auto: focas-f4b — cnc_wrmacro + cnc_wrparam writes

Closes #269
This commit is contained in:
Joseph Doherty
2026-04-26 04:54:28 -04:00
parent 71af554497
commit f48f31cfc7
15 changed files with 1066 additions and 36 deletions

View File

@@ -18,6 +18,20 @@ internal class FakeFocasClient : IFocasClient
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
/// <summary>
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrparam</c>-shaped calls
/// observed via <see cref="WriteParameterAsync"/>. Tests assert this list to
/// verify the driver routed PARAM writes through the typed entry point rather
/// than the generic <see cref="WriteAsync"/> dispatch.
/// </summary>
public List<(FocasAddress addr, FocasDataType type, object? value)> ParameterWriteLog { get; } = new();
/// <summary>
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrmacro</c>-shaped calls
/// observed via <see cref="WriteMacroAsync"/>.
/// </summary>
public List<(FocasAddress addr, object? value)> MacroWriteLog { get; } = new();
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
ConnectCount++;
@@ -46,6 +60,37 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult(status);
}
/// <summary>
/// Plan PR F4-b (issue #269) — typed parameter-write entry point. Records the
/// call in <see cref="ParameterWriteLog"/>, persists the value into
/// <see cref="Values"/> at the canonical address (so a subsequent read returns
/// the written value), and resolves to <see cref="WriteStatuses"/> if seeded
/// (lets a test simulate <c>EW_PASSWD</c> -> <see cref="FocasStatusMapper.BadUserAccessDenied"/>).
/// </summary>
public virtual Task<uint> WriteParameterAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
ParameterWriteLog.Add((address, type, value));
Values[address.Canonical] = value;
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
return Task.FromResult(status);
}
/// <summary>
/// Plan PR F4-b (issue #269) — typed macro-write entry point. See
/// <see cref="WriteParameterAsync"/> for the per-canonical-address store / log shape.
/// </summary>
public virtual Task<uint> WriteMacroAsync(
FocasAddress address, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
MacroWriteLog.Add((address, value));
Values[address.Canonical] = value;
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
return Task.FromResult(status);
}
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(

View File

@@ -0,0 +1,201 @@
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();
}
}

View File

@@ -0,0 +1,232 @@
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_wrparam</c> coverage. The driver-level
/// <c>Writes.AllowParameter</c> kill switch sits on top of the F4-a
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins; PARAM tags
/// additionally surface <see cref="SecurityClassification.Configure"/> for the
/// server-layer ACL gate.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWriteParameterTests
{
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 AllowParameter_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
{
// F4-b — the granular kill switch defaults off so a redeployed driver with
// Writes.Enabled=true still keeps PARAM writes locked until the operator team
// explicitly opts in. This is the critical defense-in-depth assertion.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowParameter = false, AllowMacro = true },
tags:
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
// Wire client was never even constructed because the gate short-circuited
// before EnsureConnectedAsync — defense in depth + lower blast radius if the
// wire client is misconfigured.
factory.Clients.ShouldBeEmpty();
}
[Fact]
public async Task AllowParameter_true_dispatches_to_typed_WriteParameterAsync()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
var log = factory.Clients[0].ParameterWriteLog;
log.Count.ShouldBe(1);
log[0].addr.Kind.ShouldBe(FocasAreaKind.Parameter);
log[0].addr.Number.ShouldBe(1815);
log[0].type.ShouldBe(FocasDataType.Int32);
log[0].value.ShouldBe(42);
// Generic write log is untouched — PARAM goes through the typed entry point.
factory.Clients[0].WriteLog.ShouldBeEmpty();
}
[Fact]
public async Task ParameterWrite_round_trip_stores_value_visible_to_subsequent_read()
{
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None);
// Round-trip: the next read sees the written value because the fake stores
// by canonical address. This exercises the same shape an integration test
// against focas-mock would: write -> read returns the written value.
factory.Clients[0].Values["PARAM:1815"].ShouldBe(42);
}
[Fact]
public async Task EW_PASSWD_from_simulator_maps_to_BadUserAccessDenied()
{
// F4-b — when the CNC reports EW_PASSWD (parameter-write switch off / unlock
// required) the status code surfaces as BadUserAccessDenied rather than
// BadNotWritable. F4-d will land the unlock workflow on top of this surface.
var drv = NewDriver(
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
tags:
[
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
],
out var factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Seed the fake to mimic EW_PASSWD propagation (mapper assigns
// BadUserAccessDenied to that EW_* code post-F4-b).
factory.Customise = () =>
{
var c = new FakeFocasClient();
c.WriteStatuses["PARAM:1815"] = FocasStatusMapper.BadUserAccessDenied;
return c;
};
// Re-init now that the customiser is in place.
await drv.ShutdownAsync(CancellationToken.None);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Param", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
}
[Fact]
public void StatusMapper_EW_PASSWD_returns_BadUserAccessDenied()
{
// EW_PASSWD == 11 per FANUC FOCAS docs. Pre-F4-b this mapped to BadNotWritable;
// F4-b remaps it so clients can branch on user-vs-driver write rejection.
FocasStatusMapper.MapFocasReturn(11).ShouldBe(FocasStatusMapper.BadUserAccessDenied);
}
[Fact]
public void Tag_classification_PARAM_yields_Configure()
{
// Server-layer ACL key — PARAM: tags require WriteConfigure group membership
// (vs MACRO: tags which require WriteOperate). Per the
// feedback_acl_at_server_layer memory the driver only declares classification;
// DriverNodeManager applies the gate.
var tag = new FocasTagDefinition(
"Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true);
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Configure);
}
[Fact]
public void Tag_classification_PARAM_non_writable_yields_ViewOnly()
{
// ViewOnly trumps the kind-based classification when the tag isn't writable.
var tag = new FocasTagDefinition(
"Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false);
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public void FocasWritesOptions_default_AllowParameter_is_false()
{
// Defense in depth: a fresh FocasWritesOptions has both granular kill
// switches off so a config-DB row that omits AllowParameter doesn't
// silently flip parameter writes on.
new FocasWritesOptions().AllowParameter.ShouldBeFalse();
new FocasDriverOptions().Writes.AllowParameter.ShouldBeFalse();
}
[Fact]
public void Dto_round_trip_preserves_AllowParameter()
{
// JSON config -> FocasDriverOptions; the Writes.AllowParameter flag must
// survive the bootstrapper's deserialize step. Use a known-empty Tags config
// and a sentinel write to verify the gate reached the driver.
const string jsonAllowed = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Tags": [{
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true
}],
"Writes": { "Enabled": true, "AllowParameter": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
// Real wire client is the unimplemented one — Connect throws, and the driver
// surfaces that as BadCommunicationError. The key is that the result is NOT
// BadNotWritable: that proves the AllowParameter gate didn't short-circuit.
var results = drv.WriteAsync(
[new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult();
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
}
[Fact]
public void Dto_default_omitted_AllowParameter_keeps_safer_default()
{
// A Writes section with just { Enabled: true } must NOT silently flip the
// granular kill switches on. PARAM writes should still get BadNotWritable.
const string json = """
{
"Backend": "unimplemented",
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
"Tags": [{
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true
}],
"Writes": { "Enabled": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
var results = drv.WriteAsync(
[new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult();
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
}