fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)

- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding
- Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations
- Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15)
- Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read
- Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs
- Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup)
- Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests)
- Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
Joseph Doherty
2026-06-20 22:43:36 -04:00
parent 3cc6a5f30d
commit ab57e53b92
26 changed files with 577 additions and 220 deletions
@@ -0,0 +1,142 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipEquipmentTagParser.TryParse"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path (Driver.AbCip.Contracts-004).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipEquipmentTagParserTests
{
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
// ── Writable field (Driver.AbCip.Contracts-001) ───────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ─────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not supported in the
/// equipment-tag flow. This test documents current behaviour so a future change to reject
/// Structure is a conscious choice.
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
}
@@ -37,6 +37,32 @@ public sealed class AbLegacyEquipmentTagTests
=> AbLegacyEquipmentTagParser.TryParse(
"""{"address":"","dataType":"Int"}""", out _).ShouldBeFalse();
// -002 regression: isArray:true without a valid positive arrayLength → scalar (null ArrayLength).
[Fact]
public void IsArray_true_with_arrayLength_zero_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":0}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_no_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_negative_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":-5}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary>
/// End-to-end driver-level proof: an AbLegacy driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
@@ -116,6 +116,39 @@ public sealed class FocasDriverMediumFindingsTests
.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- Driver.FOCAS.Contracts-002: WriteIdempotent threaded through DiscoverAsync ----
/// <summary>
/// Verifies that a tag authored with <c>WriteIdempotent: true</c> surfaces
/// <c>WriteIdempotent == true</c> on its discovered <see cref="DriverAttributeInfo"/>,
/// and that a tag with the default <c>WriteIdempotent: false</c> surfaces <c>false</c>.
/// Resolves Driver.FOCAS.Contracts-002 — the field was previously hardcoded to
/// <c>false</c> in <c>DiscoverAsync</c> and had no runtime effect.
/// </summary>
[Fact]
public async Task DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
[
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);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Idempotent").Info.WriteIdempotent
.ShouldBeTrue("a tag declared WriteIdempotent:true must surface true on DriverAttributeInfo");
builder.Variables.Single(v => v.BrowseName == "NonIdempotent").Info.WriteIdempotent
.ShouldBeFalse("a tag declared WriteIdempotent:false (the default) must surface false");
}
// ---- Driver.FOCAS-005: Volatile-guarded _health survives concurrent reads ----
/// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary>
@@ -1,14 +1,17 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Follow-up #2 — pins the three resolution forms supported by
/// <see cref="GalaxyDriver.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without
/// touching the call site.
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
/// driver and the AdminUI browser share one copy.)
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
@@ -16,7 +19,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact]
public void Literal_string_is_returned_unchanged()
{
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
}
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
@@ -27,7 +30,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, "key-from-env");
try
{
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
}
finally
{
@@ -43,7 +46,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"env:{name}"));
GalaxySecretRef.ResolveApiKey($"env:{name}"));
ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset");
}
@@ -56,7 +59,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
File.WriteAllText(path, " key-from-file \n");
try
{
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
}
finally
{
@@ -70,7 +73,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
{
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist");
}
@@ -85,7 +88,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("plain-text-key", logger);
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e =>
@@ -100,7 +103,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("dev:plain-text-key", logger);
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
@@ -115,7 +118,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try
{
var logger = new CaptureLogger();
GalaxyDriver.ResolveApiKey($"env:{name}", logger);
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
finally
@@ -150,7 +153,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try
{
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain("empty");
}
finally
@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Driver.Galaxy-019 — pins <see cref="GatewayGalaxySubscriber.ClassifyIntervalReply"/>:
/// a <c>SetBufferedUpdateInterval</c> reply is only "applied" (and therefore cacheable
/// as the last-applied interval) when the gateway returns <see cref="ProtocolStatusCode.Ok"/>.
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> means the COM-side set did NOT take
/// effect, so caching it would suppress the retry on the next subscribe — it must classify
/// as not-applied, as must any other unexpected code or a missing status.
/// </summary>
public sealed class GatewayGalaxySubscriberClassifyTests
{
/// <summary>Ok is the only code that records the interval as applied.</summary>
[Fact]
public void Ok_classifies_as_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Ok).ShouldBeTrue();
}
/// <summary>MxaccessFailure must NOT cache — the COM-side set did not apply.</summary>
[Fact]
public void MxaccessFailure_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.MxaccessFailure).ShouldBeFalse();
}
/// <summary>Any other unexpected code is treated as not-applied.</summary>
[Fact]
public void Unexpected_code_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Unspecified).ShouldBeFalse();
}
/// <summary>A missing protocol status (null) is treated as not-applied.</summary>
[Fact]
public void Null_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(null).ShouldBeFalse();
}
}