@@ -91,6 +91,44 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-c (issue #270) — typed PMC range-write entry point. Records
|
||||
/// the call in <see cref="PmcRangeWriteLog"/> and applies the bytes to
|
||||
/// <see cref="PmcByteRanges"/> at <c>(letter, pathId)</c> so a subsequent
|
||||
/// <see cref="ReadPmcRangeAsync"/> sees the updated bytes (round-trip
|
||||
/// shape). Status looked up by the canonical PMC address (e.g. <c>R100</c>)
|
||||
/// of the first byte if seeded; otherwise Good.
|
||||
/// </summary>
|
||||
public List<(string Letter, int PathId, int StartByte, byte[] Bytes)> PmcRangeWriteLog { get; } = new();
|
||||
|
||||
public virtual Task<uint> WritePmcRangeAsync(
|
||||
string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
var copy = bytes.ToArray();
|
||||
PmcRangeWriteLog.Add((letter, pathId, startByte, copy));
|
||||
// Persist into PmcByteRanges so subsequent range reads see the write — this
|
||||
// mirrors the simulator round-trip the integration tests check.
|
||||
var key = (letter.ToUpperInvariant(), pathId);
|
||||
if (!PmcByteRanges.TryGetValue(key, out var src))
|
||||
{
|
||||
src = new byte[startByte + copy.Length];
|
||||
PmcByteRanges[key] = src;
|
||||
}
|
||||
else if (src.Length < startByte + copy.Length)
|
||||
{
|
||||
var grown = new byte[startByte + copy.Length];
|
||||
Array.Copy(src, 0, grown, 0, src.Length);
|
||||
src = grown;
|
||||
PmcByteRanges[key] = src;
|
||||
}
|
||||
Array.Copy(copy, 0, src, startByte, copy.Length);
|
||||
// Status seeded by canonical PMC address of the first byte (no bit index).
|
||||
var canonical = $"{letter.ToUpperInvariant()}{startByte}";
|
||||
var status = WriteStatuses.TryGetValue(canonical, out var sx) ? sx : 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(
|
||||
|
||||
@@ -9,43 +9,32 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
public sealed class FocasPmcBitRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
|
||||
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
|
||||
/// the current bit; WriteAsync stores the full byte the driver issues.
|
||||
/// Fake client simulating PMC byte storage as a single 1024-byte buffer. Post-F4-c
|
||||
/// (issue #270) the FOCAS driver routes PMC writes through the typed
|
||||
/// <see cref="IFocasClient.WritePmcRangeAsync"/> + <see cref="IFocasClient.WritePmcBitAsync"/>
|
||||
/// entry points — the bit path performs RMW via <c>ReadPmcRangeAsync</c> +
|
||||
/// <c>WritePmcRangeAsync</c>, so this fake overrides those to drive a shared
|
||||
/// <see cref="PmcBytes"/> buffer the tests can assert against. <see cref="PmcBytes"/>
|
||||
/// is the unit-test surface; we mirror writes to <see cref="FakeFocasClient.PmcByteRanges"/>
|
||||
/// too so any helper that reads from there sees the same source of truth.
|
||||
/// </summary>
|
||||
private sealed class PmcRmwFake : FakeFocasClient
|
||||
{
|
||||
public byte[] PmcBytes { get; } = new byte[1024];
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
public override Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
|
||||
{
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
|
||||
return base.ReadAsync(address, type, ct);
|
||||
var buf = new byte[byteCount];
|
||||
Array.Copy(PmcBytes, startByte, buf, 0, byteCount);
|
||||
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
public override Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
public override Task<uint> WritePmcRangeAsync(
|
||||
string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
|
||||
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
{
|
||||
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var current = PmcBytes[address.Number];
|
||||
PmcBytes[address.Number] = Convert.ToBoolean(value)
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
return base.WriteAsync(address, type, value, ct);
|
||||
Array.Copy(bytes, 0, PmcBytes, startByte, bytes.Length);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +53,7 @@ public sealed class FocasPmcBitRmwTests
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = writableTags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ public sealed class FocasReadWriteTests
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
// F4-a flipped Writable + Writes.Enabled defaults to false for safer-by-default
|
||||
// posture (issue #268). The legacy read-write test fixture opts both back on so
|
||||
// existing assertions exercise the same wire path the original tests covered.
|
||||
Writes = new FocasWritesOptions { Enabled = true },
|
||||
// posture (issue #268). F4-c added AllowPmc on the same shape (issue #270). The
|
||||
// legacy read-write test fixture opts everything back on so existing assertions
|
||||
// exercise the same wire path the original tests covered.
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
@@ -218,7 +219,7 @@ public sealed class FocasReadWriteTests
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = true },
|
||||
Writes = new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ public sealed class FocasWriteInfrastructureTests
|
||||
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 will introduce
|
||||
// BadNotSupported branches for kinds the wire layer hasn't implemented yet.
|
||||
// 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 },
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||
@@ -84,7 +85,7 @@ public sealed class FocasWriteInfrastructureTests
|
||||
[new WriteRequest("X", (sbyte)1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
factory.Clients[0].WriteLog.Count.ShouldBe(1);
|
||||
factory.Clients[0].PmcRangeWriteLog.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -170,17 +170,20 @@ public sealed class FocasWriteMacroTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_kind_gate_does_not_affect_PMC_writes()
|
||||
public async Task Per_kind_gate_does_not_cross_contaminate_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.
|
||||
// (gated by F4-c's AllowPmc) keep flowing when the operator opted in to PMC
|
||||
// alone. Pre-F4-c this test asserted PMC needed no per-kind gate; post-F4-c
|
||||
// it asserts AllowPmc is the gate that matters for PMC, independent of the
|
||||
// PARAM/MACRO gates.
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions
|
||||
{
|
||||
Enabled = true,
|
||||
AllowParameter = false,
|
||||
AllowMacro = false,
|
||||
AllowPmc = true,
|
||||
},
|
||||
tags:
|
||||
[
|
||||
@@ -193,8 +196,8 @@ public sealed class FocasWriteMacroTests
|
||||
[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);
|
||||
// PMC routes through the typed WritePmcRangeAsync entry point post-F4-c.
|
||||
factory.Clients[0].PmcRangeWriteLog.Count.ShouldBe(1);
|
||||
factory.Clients[0].ParameterWriteLog.ShouldBeEmpty();
|
||||
factory.Clients[0].MacroWriteLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
292
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWritePmcTests.cs
Normal file
292
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWritePmcTests.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #270, plan PR F4-c — <c>pmc_wrpmcrng</c> coverage. The driver-level
|
||||
/// <c>Writes.AllowPmc</c> kill switch sits on top of the F4-a
|
||||
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins. PMC bit writes
|
||||
/// additionally exercise the read-modify-write helper (<c>pmc_wrpmcrng</c> is
|
||||
/// byte-addressed; the wire never sees a sub-byte write). PMC tags surface
|
||||
/// <see cref="SecurityClassification.Operate"/> for the server-layer ACL gate.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasWritePmcTests
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant that pre-seeds a fake's PMC byte storage (so an RMW test can verify
|
||||
/// the read-side picked up the prior byte before the bit mask). The customiser
|
||||
/// fires once per device — sufficient for the single-device tests below.
|
||||
/// </summary>
|
||||
private static FocasDriver NewDriverWithSeededPmc(
|
||||
FocasWritesOptions writes,
|
||||
FocasTagDefinition[] tags,
|
||||
string letter,
|
||||
int pathId,
|
||||
byte[] seed,
|
||||
out FakeFocasClientFactory factory)
|
||||
{
|
||||
factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.PmcByteRanges[(letter.ToUpperInvariant(), pathId)] = (byte[])seed.Clone();
|
||||
return c;
|
||||
},
|
||||
};
|
||||
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 AllowPmc_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
|
||||
{
|
||||
// F4-c — the granular kill switch defaults off so a redeployed driver with
|
||||
// Writes.Enabled=true still keeps PMC writes locked until the operator team
|
||||
// explicitly opts in. PMC is ladder working memory; defense-in-depth is
|
||||
// critical because a mistargeted bit can move motion or latch a feedhold.
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = false },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Coil", (sbyte)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.
|
||||
factory.Clients.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowPmc_true_byte_write_dispatches_to_typed_WritePmcRangeAsync()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Coil", Host, "R100", FocasDataType.Byte, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Coil", (sbyte)42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var log = factory.Clients[0].PmcRangeWriteLog;
|
||||
log.Count.ShouldBe(1);
|
||||
log[0].Letter.ShouldBe("R");
|
||||
log[0].StartByte.ShouldBe(100);
|
||||
log[0].Bytes.ShouldBe(new byte[] { 42 });
|
||||
// Generic WriteAsync path is untouched — PMC byte goes through the typed entry point.
|
||||
factory.Clients[0].WriteLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PMC_bit_write_set_RMW_preserves_zero_byte_writes_only_target_bit()
|
||||
{
|
||||
// Prior byte = 0b0000_0000; set bit 3 → write byte = 0b0000_1000.
|
||||
var drv = NewDriverWithSeededPmc(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags: [new FocasTagDefinition("G50_3", Host, "G50.3", FocasDataType.Bit, Writable: true)],
|
||||
letter: "G", pathId: 1,
|
||||
seed: PmcBuffer(byteAddr: 50, value: 0b0000_0000),
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("G50_3", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var log = factory.Clients[0].PmcRangeWriteLog.Single();
|
||||
log.Letter.ShouldBe("G");
|
||||
log.StartByte.ShouldBe(50);
|
||||
log.Bytes.ShouldBe(new byte[] { 0b0000_1000 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PMC_bit_write_set_preserves_other_bits_already_set()
|
||||
{
|
||||
// Prior byte = 0b1111_0000; set bit 0 → write byte = 0b1111_0001.
|
||||
var drv = NewDriverWithSeededPmc(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)],
|
||||
letter: "R", pathId: 1,
|
||||
seed: PmcBuffer(byteAddr: 50, value: 0b1111_0000),
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("R50_0", true)], CancellationToken.None);
|
||||
|
||||
var log = factory.Clients[0].PmcRangeWriteLog.Single();
|
||||
log.Bytes.ShouldBe(new byte[] { 0b1111_0001 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PMC_bit_write_clear_preserves_other_bits()
|
||||
{
|
||||
// Prior byte = 0b1111_1111; clear bit 0 → write byte = 0b1111_1110.
|
||||
var drv = NewDriverWithSeededPmc(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags: [new FocasTagDefinition("R50_0", Host, "R50.0", FocasDataType.Bit, Writable: true)],
|
||||
letter: "R", pathId: 1,
|
||||
seed: PmcBuffer(byteAddr: 50, value: 0xFF),
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("R50_0", false)], CancellationToken.None);
|
||||
|
||||
var log = factory.Clients[0].PmcRangeWriteLog.Single();
|
||||
log.Bytes.ShouldBe(new byte[] { 0b1111_1110 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_consecutive_bit_writes_in_same_byte_serialise()
|
||||
{
|
||||
// Each bit write does its own RMW (Read range -> mask -> Write range). Eight
|
||||
// consecutive bit-set writes on R100 starting from 0 must compose to 0xFF.
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new FocasTagDefinition($"Bit{b}", Host, $"R100.{b}", FocasDataType.Bit, Writable: true))
|
||||
.ToArray();
|
||||
var drv = NewDriverWithSeededPmc(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowPmc = true },
|
||||
tags: tags,
|
||||
letter: "R", pathId: 1,
|
||||
seed: PmcBuffer(byteAddr: 100, value: 0),
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var b = 0; b < 8; b++)
|
||||
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
|
||||
|
||||
var fake = factory.Clients[0];
|
||||
// 8 RMW round-trips, each writing the cumulative byte back.
|
||||
fake.PmcRangeWriteLog.Count.ShouldBe(8);
|
||||
fake.PmcByteRanges[("R", 1)][100].ShouldBe((byte)0xFF);
|
||||
// Every write hit the same byte address 100.
|
||||
fake.PmcRangeWriteLog.ShouldAllBe(e => e.StartByte == 100 && e.Bytes.Length == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_classification_PMC_writable_yields_Operate()
|
||||
{
|
||||
// Server-layer ACL key — PMC tags require WriteOperate group membership
|
||||
// (mirrors MACRO; PARAM is the higher-friction Configure tier).
|
||||
var tag = new FocasTagDefinition(
|
||||
"Coil", Host, "R100.3", FocasDataType.Bit, Writable: true);
|
||||
|
||||
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_classification_PMC_non_writable_yields_ViewOnly()
|
||||
{
|
||||
var tag = new FocasTagDefinition(
|
||||
"Coil", Host, "R100.3", FocasDataType.Bit, Writable: false);
|
||||
|
||||
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocasWritesOptions_default_AllowPmc_is_false()
|
||||
{
|
||||
// Defense in depth: a fresh FocasWritesOptions has every granular kill
|
||||
// switch off. A config row that omits AllowPmc must NOT silently flip PMC
|
||||
// writes on.
|
||||
new FocasWritesOptions().AllowPmc.ShouldBeFalse();
|
||||
new FocasDriverOptions().Writes.AllowPmc.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_AllowPmc()
|
||||
{
|
||||
// JSON config -> FocasDriverOptions; the Writes.AllowPmc flag must survive
|
||||
// the bootstrapper's deserialize step. Sentinel: a write at a configured
|
||||
// PMC tag should NOT short-circuit to BadNotWritable when AllowPmc=true is
|
||||
// set in the JSON (the unimplemented backend will surface BadCommunicationError
|
||||
// instead).
|
||||
const string jsonAllowed = """
|
||||
{
|
||||
"Backend": "unimplemented",
|
||||
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||
"Tags": [{
|
||||
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100", "DataType": "Byte", "Writable": true
|
||||
}],
|
||||
"Writes": { "Enabled": true, "AllowPmc": true }
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed);
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var results = drv.WriteAsync(
|
||||
[new WriteRequest("P", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult();
|
||||
// Key assertion: NOT BadNotWritable — that proves the AllowPmc gate didn't short-circuit.
|
||||
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_default_omitted_AllowPmc_keeps_safer_default()
|
||||
{
|
||||
// A Writes section with just { Enabled: true } must NOT silently flip the
|
||||
// granular kill switch on. PMC 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": "R100", "DataType": "Byte", "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", (sbyte)1)], CancellationToken.None).GetAwaiter().GetResult();
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
private static byte[] PmcBuffer(int byteAddr, byte value)
|
||||
{
|
||||
// Allocate enough buffer to hold the byteAddr index, fill the chosen byte.
|
||||
var buf = new byte[byteAddr + 1];
|
||||
buf[byteAddr] = value;
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user