fix(driver-focas): equipment tags run the capability-matrix pre-flight authored tags get (R2-11, 05/UNDER-6)

This commit is contained in:
Joseph Doherty
2026-07-13 11:05:02 -04:00
parent e294416087
commit 24435efa8b
3 changed files with 77 additions and 2 deletions
@@ -19,7 +19,7 @@
{ "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "pending", "blockedBy": ["T17"] },
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "pending", "blockedBy": [] },
{ "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "pending", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "pending", "blockedBy": ["T20"] },
@@ -68,7 +68,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => FocasEquipmentTagParser.TryParse(r, out var d) ? d : null);
ResolveEquipmentTagRef);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -234,6 +234,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal bool IsParsedAddressCached(string reference) =>
_parsedAddressesByTagName.ContainsKey(reference);
// Resolves an equipment-tag TagConfig reference into a FocasTagDefinition, applying the SAME
// capability pre-flight authored tags get at InitializeAsync (:105-119) — R2-11 (05/UNDER-6). A
// reference whose address is unparseable, whose bound device is unknown, or whose address violates
// the device series' capability matrix returns null ⇒ resolver miss ⇒ BadNodeIdUnknown at read time,
// instead of a wrong-status read reaching the wire. The negative is cached by the resolver.
private FocasTagDefinition? ResolveEquipmentTagRef(string reference)
{
if (!FocasEquipmentTagParser.TryParse(reference, out var def)) return null;
var parsed = FocasAddress.TryParse(def.Address);
if (parsed is null) return null;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) return null;
return FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is null ? def : null;
}
// Resolves a tag definition to its parsed FocasAddress, caching the result so that
// equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the
// address string on every ReadAsync / WriteAsync hot-path call.
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 (05/UNDER-6): an equipment-tag reference whose address violates the device series'
/// capability matrix must fail to RESOLVE (surfacing <c>BadNodeIdUnknown</c>) — the same pre-flight
/// authored tags get at <c>InitializeAsync</c> — instead of reaching the wire and failing later with a
/// wire error. A capability-valid equipment tag still resolves + reads.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasEquipmentTagCapabilityGateTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver() => new(new FocasDriverOptions
{
// Series 16i: macro range 0..999 — MACRO:9500 is out of range.
Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
private static string EquipTag(string address) =>
$"{{\"address\":\"{address}\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"{Host}\"}}";
[Fact]
public async Task Capability_violating_equipment_tag_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Capability_valid_equipment_tag_resolves_and_reads()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None);
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Equipment_tag_for_unknown_device_does_not_resolve()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var badDevice = "{\"address\":\"MACRO:100\",\"dataType\":\"Int32\",\"deviceHostAddress\":\"focas://10.9.9.9:8193\"}";
var results = await drv.ReadAsync([badDevice], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
}