v3(focas): adopt Modbus RawTagEntry exemplar (multi-device)

- Rename FocasEquipmentTagParser -> FocasTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-{ heuristic + the
  deviceHostAddress blob key; def.Name = rawPath. Add inverse ToTagConfig.
  writable: absent -> read-only, explicit true honoured (note preserved).
- FocasDriverOptions.Tags -> RawTags (IReadOnlyList<RawTagEntry>).
- FocasDriver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  RawTags via FromTagConfig; multi-device routing threads RawTagEntry.DeviceName
  -> ResolveDeviceHost match against options.Devices -> def.DeviceHostAddress
  (TODO v3 WaveC: live host from Device row DeviceConfig). Tolerant per-tag
  skip+log (mapper/address/matrix miss -> BadNodeIdUnknown); unknown device
  fails init fast.
- Factory: bind List<RawTagEntry> RawTags; retire FocasTagDto/ParseDataType.
- CLI FocasCommandBase.BuildOptions -> RawTags via ToTagConfig + DeviceName.
- Tests: FocasRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; rewrite retired blob-ref tests (equipment-tag/capability-gate/
  resolve-host) onto authored RawPath. 272 driver + 52 CLI green.
This commit is contained in:
Joseph Doherty
2026-07-15 20:19:55 -04:00
parent c379e246d0
commit 8b2c3fa04c
23 changed files with 467 additions and 350 deletions
@@ -74,9 +74,12 @@ public sealed class FocasCommandBaseBuildOptionsTests
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
}
/// <summary>Verifies that BuildOptions forwards tag list verbatim.</summary>
/// <summary>
/// Verifies that BuildOptions turns each typed def into a v3 <c>RawTagEntry</c> — RawPath = def.Name,
/// DeviceName = def.DeviceHostAddress, and a TagConfig blob that round-trips through the mapper.
/// </summary>
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
public void BuildOptions_maps_tags_to_rawtag_entries()
{
var sut = new ProbeOnly { CncHost = "h" };
var tag = new FocasTagDefinition(
@@ -88,7 +91,14 @@ public sealed class FocasCommandBaseBuildOptionsTests
var options = sut.Invoke([tag]);
options.Tags.Count.ShouldBe(1);
options.Tags[0].ShouldBeSameAs(tag);
options.RawTags.Count.ShouldBe(1);
var entry = options.RawTags[0];
entry.RawPath.ShouldBe("t");
entry.DeviceName.ShouldBe("focas://h:8193");
entry.WriteIdempotent.ShouldBeFalse();
FocasTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue();
round.Address.ShouldBe("R100");
round.DataType.ShouldBe(FocasDataType.Int16);
round.Writable.ShouldBeFalse();
}
}
@@ -15,7 +15,7 @@ public sealed class FocasAlarmProjectionTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Tags = [],
RawTags = [],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
@@ -19,11 +19,11 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -51,7 +51,7 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -77,7 +77,7 @@ public sealed class FocasCapabilityTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -181,11 +181,11 @@ public sealed class FocasCapabilityTests
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -17,7 +17,7 @@ public sealed class FocasConnectBackoffTests
private static FocasDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions
{
Enabled = probeEnabled,
@@ -27,11 +27,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
// DeviceHostAddress has a port typo — not in Devices
// DeviceHostAddress (→ RawTagEntry.DeviceName) has a port typo — not in Devices
new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -49,11 +49,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
// Correct address so address validation passes
new FocasTagDefinition("TypoTag", "focas://10.0.0.99:8193", "R100", FocasDataType.Byte),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -74,11 +74,11 @@ public sealed class FocasDriverMediumFindingsTests
new FocasDeviceOptions("focas://10.0.0.5:8193"),
new FocasDeviceOptions("focas://10.0.0.6:8193"),
],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
@@ -97,12 +97,12 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
// Writable: true is the default — must still be ViewOnly
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, Writable: true),
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -132,11 +132,11 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("Idempotent", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, WriteIdempotent: true),
new FocasTagDefinition("NonIdempotent", "focas://10.0.0.5:8193", "R200", FocasDataType.Int16, WriteIdempotent: false),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -162,7 +162,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -191,7 +191,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -227,7 +227,7 @@ public sealed class FocasDriverMediumFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -5,57 +5,54 @@ 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.
/// R2-11 (05/UNDER-6): an authored raw tag whose address violates the device series' capability matrix
/// must be dropped from the driver's table at <c>InitializeAsync</c> and surface <c>BadNodeIdUnknown</c>
/// at read (a resolver miss) — instead of reaching the wire and failing later with a wire error. A
/// capability-valid tag still resolves + reads. An authored tag routed to an unknown device is a
/// driver-level config error that fails init fast (Driver.FOCAS-003).
/// </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.
private static FocasDriver NewDriver(params FocasTagDefinition[] tags) => new(new FocasDriverOptions
{
// Series 16i: macro range 0..999 — MACRO:9500 is out of range.
Devices = [new FocasDeviceOptions(Host, Series: FocasCncSeries.Sixteen_i)],
RawTags = FocasRawTags.Entries(tags),
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()
public async Task Capability_violating_authored_tag_does_not_resolve()
{
var drv = NewDriver();
var drv = NewDriver(new FocasTagDefinition("eq/Bad", Host, "MACRO:9500", FocasDataType.Int32));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:9500")], CancellationToken.None);
var results = await drv.ReadAsync(["eq/Bad"], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Capability_valid_equipment_tag_resolves_and_reads()
public async Task Capability_valid_authored_tag_resolves_and_reads()
{
var drv = NewDriver();
var drv = NewDriver(new FocasTagDefinition("eq/Good", Host, "MACRO:100", FocasDataType.Int32));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([EquipTag("MACRO:100")], CancellationToken.None);
var results = await drv.ReadAsync(["eq/Good"], CancellationToken.None);
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Equipment_tag_for_unknown_device_does_not_resolve()
public async Task Authored_tag_for_unknown_device_fails_init()
{
var drv = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var drv = NewDriver(new FocasTagDefinition("eq/BadDevice", "focas://10.9.9.9:8193", "MACRO:100", FocasDataType.Int32));
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);
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
ex.Message.ShouldContain("not in the Devices list");
}
}
@@ -1,65 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: the runtime is now
/// STRICT — a typo'd <c>dataType</c> rejects the tag (<c>TryParse</c> ⇒ <see langword="false"/> ⇒
/// <c>BadNodeIdUnknown</c>); FOCAS tags remain forced read-only (<c>Writable == false</c> regardless of any
/// authored <c>writable:true</c>), and <c>Inspect</c> reports the typo + the write-request.
/// </summary>
public sealed class FocasEquipmentTagParserStrictnessTests
{
[Fact]
public void Typo_dataType_rejects_the_tag()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(FocasDataType.Int32);
}
[Fact]
public void Writable_is_always_false_even_when_requested_true()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_is_false_when_absent()
{
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int32\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Inspect_warns_on_writable_true_request()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
}
[Fact]
public void Inspect_clean_read_only_config_has_no_warnings()
{
FocasEquipmentTagParser.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
}
}
@@ -4,61 +4,68 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// v3 authored-tag surface for FOCAS. Under v3 a tag's wire reference is its <b>RawPath</b> and the
/// driver builds its <c>RawPath → definition</c> table from the authored <c>RawTagEntry</c> list via
/// <see cref="FocasTagDefinitionFactory.FromTagConfig"/> at <c>InitializeAsync</c> — there is no longer
/// a blob-parse fallback where the read reference IS the raw TagConfig JSON (a miss is a miss ⇒
/// BadNodeIdUnknown). These tests cover the mapper's parse/reject cases and the driver's authored-tag
/// read path.
/// </summary>
[Trait("Category", "Unit")]
public class FocasEquipmentTagTests
{
private const string RawPath = "area/line/lathe/Run";
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
public void Maps_tagconfig_into_a_definition_keyed_by_rawpath()
{
var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
FocasEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.DeviceHostAddress.ShouldBe("focas://10.0.0.5:8193");
var json = """{"address":"R100","dataType":"Byte"}""";
FocasTagDefinitionFactory.FromTagConfig(json, RawPath, out var def).ShouldBeTrue();
def!.Name.ShouldBe(RawPath);
def.Address.ShouldBe("R100");
def.DataType.ShouldBe(FocasDataType.Byte);
}
[Fact]
public void Rejects_a_non_address_blob()
=> FocasEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
=> FocasTagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> FocasEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
=> FocasTagDefinitionFactory.FromTagConfig("not json", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_address_as_a_json_number()
=> FocasEquipmentTagParser.TryParse(
"""{"address":100,"dataType":"Int32"}""", out _).ShouldBeFalse();
=> FocasTagDefinitionFactory.FromTagConfig(
"""{"address":100,"dataType":"Int32"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_a_blank_address()
=> FocasEquipmentTagParser.TryParse(
"""{"address":" ","dataType":"Int32"}""", out _).ShouldBeFalse();
=> FocasTagDefinitionFactory.FromTagConfig(
"""{"address":" ","dataType":"Int32"}""", RawPath, out _).ShouldBeFalse();
// FOCAS's only mandatory address field is the canonical Address STRING (no narrow numeric
// TagConfig field like Modbus's ushort register address), so the Modbus "numeric out-of-range"
// rejection case has no analog here — covered instead by the json-number + blank-address cases.
/// <summary>
/// End-to-end driver-level proof: a FOCAS driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
/// definition and the read goes to the wire instead of returning BadNodeIdUnknown.
/// End-to-end driver-level proof: a FOCAS driver serving an authored raw tag resolves it by its
/// RawPath and reads the wire instead of returning BadNodeIdUnknown.
/// </summary>
[Fact]
public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
public async Task Driver_resolves_an_authored_rawtag_and_reads_instead_of_BadNodeIdUnknown()
{
var json = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } } };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
var r = await drv.ReadAsync([RawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
@@ -66,33 +73,47 @@ public class FocasEquipmentTagTests
}
/// <summary>
/// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed
/// <see cref="FocasAddress"/> for a resolver-produced (equipment) tag must be
/// cached after the first read so subsequent reads and writes skip re-parsing
/// the raw TagConfig JSON on every call.
/// A read reference that is NOT an authored RawPath (e.g. a raw TagConfig JSON — the retired
/// equipment-tag blob-ref) is a resolver miss and surfaces BadNodeIdUnknown; the blob-parse
/// fallback is gone in v3.
/// </summary>
[Fact]
public async Task Equipment_tag_parsed_address_is_cached_after_first_read()
public async Task Blob_reference_is_a_resolver_miss_in_v3()
{
var equipRef = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}""";
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [],
RawTags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-eq", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var blob = """{"address":"R100","dataType":"Byte"}""";
var r = await drv.ReadAsync([blob], CancellationToken.None);
r[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
/// <summary>
/// Regression guard for Driver.FOCAS-008: the parsed <see cref="FocasAddress"/> for an authored
/// tag is cached at <c>InitializeAsync</c> (v3 pre-caches the whole table) so reads/writes skip
/// re-parsing the address string on every call.
/// </summary>
[Fact]
public async Task Authored_tag_parsed_address_is_cached_at_init()
{
var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } } };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition(RawPath, "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-eq-cache", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Before any read the parsed address must NOT be in the cache.
drv.IsParsedAddressCached(equipRef).ShouldBeFalse();
// After a successful read the parsed address MUST be cached.
var r = await drv.ReadAsync([equipRef], CancellationToken.None);
// v3 builds the table eagerly at init, so the parsed address is cached before any read.
drv.IsParsedAddressCached(RawPath).ShouldBeTrue();
var r = await drv.ReadAsync([RawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
drv.IsParsedAddressCached(equipRef).ShouldBeTrue();
}
}
@@ -14,7 +14,7 @@ public sealed class FocasHandleRecycleTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
HandleRecycle = new FocasHandleRecycleOptions
{
@@ -47,7 +47,7 @@ public sealed class FocasHandleRecycleTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
@@ -110,7 +110,7 @@ public sealed class FocasIoSerializationTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromMilliseconds(150),
}, "drv-1", factory);
@@ -133,7 +133,7 @@ public sealed class FocasIoSerializationTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromMilliseconds(120),
}, "drv-1", factory);
@@ -43,7 +43,7 @@ public sealed class FocasLowFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -73,11 +73,11 @@ public sealed class FocasLowFindingsTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition(
"X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -59,7 +59,7 @@ public sealed class FocasPmcBitRmwTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, fake);
@@ -45,7 +45,8 @@ public sealed class FocasPollErrorHealthTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)],
// DeviceName "…:9999" is not in Devices → InitializeAsync fails fast → Faulted.
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
@@ -0,0 +1,30 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Test helper bridging the v2 authoring shape (a typed <see cref="FocasTagDefinition"/>) to the v3
/// delivery shape (<see cref="RawTagEntry"/>). Under v3 the driver no longer takes pre-declared
/// definitions — the deploy artifact hands it authored raw tags (RawPath + TagConfig blob + DeviceName),
/// and the driver rebuilds the typed definitions via <see cref="FocasTagDefinitionFactory"/> and routes
/// each to its device by <see cref="RawTagEntry.DeviceName"/>. These tests keep expressing their intent
/// as typed definitions; this helper serialises each back to its TagConfig blob (via
/// <see cref="FocasTagDefinitionFactory.ToTagConfig"/>) and packages it as a <see cref="RawTagEntry"/>
/// whose RawPath is the def's <c>Name</c> and whose DeviceName is the def's <c>DeviceHostAddress</c> — so
/// the round-trip through the real mapper + device match reproduces the same definition the test authored,
/// keyed by the same name the read/write/subscribe call sites use.
/// </summary>
internal static class FocasRawTags
{
/// <summary>Serialises one typed definition into its <see cref="RawTagEntry"/> (RawPath = def.Name, DeviceName = def.DeviceHostAddress).</summary>
/// <param name="def">The typed definition to convert.</param>
/// <returns>The equivalent <see cref="RawTagEntry"/>.</returns>
public static RawTagEntry Entry(FocasTagDefinition def)
=> new(def.Name, FocasTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress);
/// <summary>Serialises a sequence of typed definitions into <see cref="RawTagEntry"/> records.</summary>
/// <param name="defs">The typed definitions to convert.</param>
/// <returns>The equivalent raw-tag entries.</returns>
public static IReadOnlyList<RawTagEntry> Entries(IEnumerable<FocasTagDefinition> defs)
=> [.. defs.Select(Entry)];
}
@@ -14,7 +14,7 @@ public sealed class FocasReadWriteTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
@@ -221,11 +221,11 @@ public sealed class FocasReadWriteTests
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
RawTags = FocasRawTags.Entries(
[
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
],
]),
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// CONV-4 — <see cref="FocasDriver.ResolveHost"/> must resolve an equipment-tag reference (raw
/// TagConfig JSON) to its OWN device host so per-host breaker keys are correct for equipment
/// tags. An address-less equipment ref (FOCAS coalesces a missing <c>deviceHostAddress</c> to
/// <c>""</c>) must fall back rather than key an empty host.
/// CONV-4 — <see cref="FocasDriver.ResolveHost"/> must resolve an authored raw tag (by its RawPath)
/// to its OWN device host so per-host breaker keys are correct for multi-device drivers. A reference
/// that resolves to no authored tag falls back to the first device rather than keying an empty host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasResolveHostTests
@@ -16,15 +15,16 @@ public sealed class FocasResolveHostTests
private const string DeviceA = "focas://10.0.0.5:8193";
private const string DeviceB = "focas://10.0.0.6:8193";
// FOCAS's equipment-ref resolver validates against the initialized Devices map, so — unlike
// the other drivers — the driver must be initialized (as it always is when ResolveHost runs at
// runtime) before an equipment ref resolves. Probe off / no connect: init just parses devices.
private static async Task<FocasDriver> NewDriverAsync()
// The driver builds its RawPath → definition table (with each tag's resolved device host) at
// InitializeAsync, so the driver must be initialized before ResolveHost can resolve a tag — as it
// always is when ResolveHost runs at runtime. Probe off / no connect: init just parses devices + tags.
private static async Task<FocasDriver> NewDriverAsync(params FocasTagDefinition[] tags)
{
var drv = new FocasDriver(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceA), new FocasDeviceOptions(DeviceB)],
RawTags = FocasRawTags.Entries(tags),
Probe = new FocasProbeOptions { Enabled = false },
},
"focas-1", new FakeFocasClientFactory());
@@ -32,18 +32,17 @@ public sealed class FocasResolveHostTests
return drv;
}
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
/// <summary>An authored tag routed to device B resolves to device B's host, not the first device.</summary>
[Fact]
public async Task ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
public async Task ResolveHost_AuthoredTag_ReturnsItsOwnDeviceHost()
{
var drv = await NewDriverAsync();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","address":"R100","dataType":"Byte"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
var drv = await NewDriverAsync(new FocasTagDefinition("eq/onB", DeviceB, "R100", FocasDataType.Byte));
drv.ResolveHost("eq/onB").ShouldBe(DeviceB);
}
/// <summary>An equipment ref with no deviceHostAddress falls back to the first device, not an empty host.</summary>
/// <summary>A reference that resolves to no authored tag falls back to the first device, not an empty host.</summary>
[Fact]
public async Task ResolveHost_EquipmentTagRef_WithoutDeviceHost_FallsBack()
public async Task ResolveHost_UnresolvedRef_FallsBack()
{
var drv = await NewDriverAsync();
var json = """{"address":"R100","dataType":"Byte"}""";
@@ -0,0 +1,70 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// R2-11 Phase C (+ 05/UNDER-1) strictness surface for the v3 <see cref="FocasTagDefinitionFactory"/>:
/// the runtime is STRICT — a typo'd <c>dataType</c> rejects the tag (<c>FromTagConfig</c> ⇒
/// <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>); the produced definition's <c>Name</c> is the
/// RawPath (not the blob). Writability lives on the Tag entity / node ACL, so the authored FOCAS
/// TagConfig has no <c>writable</c> key: an absent key ⇒ read-only (<c>Writable == false</c>), while an
/// explicit <c>writable:true</c> is honoured for the driver's internal write gate (the wire is still
/// read-only, which <c>Inspect</c> warns about).
/// </summary>
public sealed class FocasTagDefinitionFactoryStrictnessTests
{
private const string RawPath = "area/line/equip/tag";
[Fact]
public void Typo_dataType_rejects_the_tag()
=> FocasTagDefinitionFactory.FromTagConfig(
"{\"address\":\"D0100\",\"dataType\":\"Int322\"}", RawPath, out _).ShouldBeFalse();
[Fact]
public void Valid_dataType_still_parses_and_names_by_rawPath()
{
FocasTagDefinitionFactory.FromTagConfig(
"{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue();
def.DataType.ShouldBe(FocasDataType.Int32);
def.Name.ShouldBe(RawPath);
def.Address.ShouldBe("D0100");
// The device host is not carried in the blob — the driver fills it from the RawTagEntry.DeviceName.
def.DeviceHostAddress.ShouldBe("");
}
[Fact]
public void Writable_is_honoured_when_requested_true()
{
FocasTagDefinitionFactory.FromTagConfig(
"{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}", RawPath, out var def).ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_is_false_when_absent()
{
FocasTagDefinitionFactory.FromTagConfig(
"{\"address\":\"D0100\",\"dataType\":\"Int32\"}", RawPath, out var def).ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Inspect_warns_on_writable_true_request()
{
var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
warnings.ShouldContain(w => w.Contains("read-only") && w.Contains("writable"));
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int322\"}");
warnings.ShouldContain(w => w.Contains("Int322") && w.Contains("dataType"));
}
[Fact]
public void Inspect_clean_read_only_config_has_no_warnings()
=> FocasTagDefinitionFactory.Inspect("{\"address\":\"D0100\",\"dataType\":\"Int32\"}").ShouldBeEmpty();
}