Merge R2-11 TagConfig consolidation (arch-review round 2) [PR #434]
Findings 01/C-1, 01/P-1 (single TagConfigIntent.Parse in Commons, 4 copies delegate, parse-once) + 05 CONV-2/UNDER-1/UNDER-6 (shared strict TagConfigJson readers, per-driver Inspect() + writable key, FOCAS capability pre-flight, Modbus probe timeoutMs, deploy-gate Warn|Error). Phase C runtime-strict flip deferred. T22/T24 deferred. Auto-merged AddressSpaceApplier.cs + DriverHostActor.cs with R2-04/R2-10; verified OpcUaServer.Tests 286/286 + Runtime.Tests 396/396. Build clean.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DeviceConfigIntent"/> device-host extraction + normalization semantics (ported
|
||||
/// from the historical composer statics; R2-11): null on blank/non-object/non-string/whitespace,
|
||||
/// trim+lower normalization, and idempotence.
|
||||
/// </summary>
|
||||
public sealed class DeviceConfigIntentTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("{\"HostAddress\":\"10.0.0.5:8193\"}", "10.0.0.5:8193")]
|
||||
[InlineData("{\"HostAddress\":\" HOST:8193 \"}", "host:8193")] // trim + lower
|
||||
[InlineData("{\"HostAddress\":\"HOST:8193\",\"other\":1}", "host:8193")]
|
||||
// null-yielding cases
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(" ", null)]
|
||||
[InlineData("not json {", null)]
|
||||
[InlineData("[1,2]", null)] // non-object root
|
||||
[InlineData("{\"HostAddress\":123}", null)] // non-string
|
||||
[InlineData("{\"HostAddress\":\" \"}", null)] // whitespace value
|
||||
[InlineData("{\"other\":\"x\"}", null)] // absent
|
||||
public void TryExtractHost(string? cfg, string? expected)
|
||||
{
|
||||
DeviceConfigIntent.TryExtractHost(cfg).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" HOST:8193 ", "host:8193")]
|
||||
[InlineData("host:8193", "host:8193")]
|
||||
public void NormalizeHost_trims_and_lowercases(string raw, string expected)
|
||||
{
|
||||
DeviceConfigIntent.NormalizeHost(raw).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeHost_is_idempotent()
|
||||
{
|
||||
var once = DeviceConfigIntent.NormalizeHost(" HOST:8193 ");
|
||||
DeviceConfigIntent.NormalizeHost(once).ShouldBe(once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The single byte-parity authority for the cross-driver TagConfig platform intents (R2-11, 01/C-1).
|
||||
/// Ports the three retired <c>OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests</c> tables and
|
||||
/// adds the <see cref="TagConfigIntent.FullName"/> / <see cref="TagConfigIntent.ExplicitFullName"/>
|
||||
/// distinction plus the never-throws property.
|
||||
/// </summary>
|
||||
public sealed class TagConfigIntentTests
|
||||
{
|
||||
// ---- FullName / ExplicitFullName ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y", "X.Y")]
|
||||
[InlineData("{\"FullName\":\" \"}", " ", " ")] // whitespace string returned verbatim (not trimmed)
|
||||
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", "A.B", "A.B")]
|
||||
// fallbacks: FullName = raw blob, ExplicitFullName = null
|
||||
[InlineData("{\"region\":\"HoldingRegisters\"}", "{\"region\":\"HoldingRegisters\"}", null)]
|
||||
[InlineData("{\"FullName\":123}", "{\"FullName\":123}", null)]
|
||||
[InlineData("[1,2]", "[1,2]", null)]
|
||||
[InlineData("not json {", "not json {", null)]
|
||||
[InlineData(" ", " ", null)]
|
||||
public void FullName_and_ExplicitFullName(string cfg, string expectedFullName, string? expectedExplicit)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(cfg);
|
||||
intent.FullName.ShouldBe(expectedFullName);
|
||||
intent.ExplicitFullName.ShouldBe(expectedExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_null_yields_empty_FullName_and_null_explicit()
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(null);
|
||||
intent.FullName.ShouldBe(string.Empty);
|
||||
intent.ExplicitFullName.ShouldBeNull();
|
||||
intent.Alarm.ShouldBeNull();
|
||||
intent.IsHistorized.ShouldBeFalse();
|
||||
intent.IsArray.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Alarm (ported from ExtractTagAlarmTests) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
|
||||
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
|
||||
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
|
||||
[InlineData("not json", false, null, 0)]
|
||||
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
|
||||
[InlineData("{\"alarm\":{\"severity\":\"high\"}}", true, "AlarmCondition", 500)] // non-number severity → 500
|
||||
[InlineData("{\"alarm\":{\"alarmType\":123}}", true, "AlarmCondition", 500)] // non-string type → default
|
||||
public void Alarm_parses_or_null(string cfg, bool present, string? type, int sev)
|
||||
{
|
||||
var alarm = TagConfigIntent.Parse(cfg).Alarm;
|
||||
if (!present) { alarm.ShouldBeNull(); return; }
|
||||
alarm!.AlarmType.ShouldBe(type);
|
||||
alarm.Severity.ShouldBe(sev);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
|
||||
public void Alarm_historizeToAveva(string cfg, bool? expected)
|
||||
{
|
||||
TagConfigIntent.Parse(cfg).Alarm!.HistorizeToAveva.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- Historize ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true}", true, null)]
|
||||
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}", true, "P.L.X")]
|
||||
[InlineData("{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}", false, null)]
|
||||
[InlineData("{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)] // whitespace → null
|
||||
[InlineData("{\"FullName\":\"A\",\"isHistorized\":\"yes\"}", false, null)] // non-bool → false
|
||||
[InlineData("{\"FullName\":\"A\"}", false, null)]
|
||||
public void Historize(string cfg, bool expectedHistorized, string? expectedTagname)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(cfg);
|
||||
intent.IsHistorized.ShouldBe(expectedHistorized);
|
||||
intent.HistorianTagname.ShouldBe(expectedTagname);
|
||||
}
|
||||
|
||||
// ---- Array (ported from ExtractTagArrayTests) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
|
||||
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
|
||||
[InlineData(null, false, null)]
|
||||
[InlineData("", false, null)]
|
||||
[InlineData("not json {", false, null)]
|
||||
[InlineData("[1,2]", false, null)]
|
||||
[InlineData("{\"isArray\":\"yes\"}", false, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
|
||||
public void Array_parses_or_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
|
||||
{
|
||||
var intent = TagConfigIntent.Parse(cfg);
|
||||
intent.IsArray.ShouldBe(expectedIsArray);
|
||||
intent.ArrayLength.ShouldBe(expectedLength);
|
||||
}
|
||||
|
||||
// ---- never-throws property over an adversarial set ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json {")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"just a string\"")]
|
||||
[InlineData("{\"alarm\":42,\"isArray\":\"x\",\"arrayLength\":true,\"isHistorized\":123}")]
|
||||
public void Parse_never_throws(string? cfg)
|
||||
{
|
||||
Should.NotThrow(() => TagConfigIntent.Parse(cfg));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's <em>explicit</em>-FullName
|
||||
/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to
|
||||
/// <c>TagConfigIntent.Parse(...).ExplicitFullName</c>. Distinct from the walker/composer raw-blob
|
||||
/// fallback: <c>GalaxyTagMissingReference</c> fires whenever the TagConfig has no usable
|
||||
/// <em>explicit</em> string <c>FullName</c> — i.e. absent / non-string / whitespace / non-object /
|
||||
/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on
|
||||
/// the unmodified tree and stay green through the swap.
|
||||
/// </summary>
|
||||
public sealed class DraftValidatorGalaxyFullNameCorpusTests
|
||||
{
|
||||
[Theory]
|
||||
// Present, non-blank string FullName → rule does NOT fire
|
||||
[InlineData("{\"FullName\":\"X.Y\"}", false)]
|
||||
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)]
|
||||
// Absent / non-string / whitespace / non-object / malformed → rule FIRES
|
||||
[InlineData("{}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\"}", true)]
|
||||
[InlineData("{\"FullName\":123}", true)]
|
||||
[InlineData("{\"FullName\":\" \"}", true)]
|
||||
[InlineData("[1,2]", true)]
|
||||
[InlineData("not json {", true)]
|
||||
public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire)
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances =
|
||||
[
|
||||
new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1",
|
||||
Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
||||
},
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new Tag
|
||||
{
|
||||
TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1",
|
||||
Name = "galaxytag", FolderPath = null, DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var fired = DraftValidator.Validate(draft)
|
||||
.Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
|
||||
fired.ShouldBe(shouldFire);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the shared strict-capable TagConfig field readers (R2-11, 05/CONV-2): the
|
||||
/// <see cref="TagConfigJson.TryReadEnum{TEnum}"/> Absent/Valid/Invalid matrix (case-insensitive),
|
||||
/// <see cref="TagConfigJson.ReadEnumOrDefault{TEnum}"/> lenient equivalence to the retired copies,
|
||||
/// <see cref="TagConfigJson.ReadWritable"/> explicit-false-only semantics, and
|
||||
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/> messages.
|
||||
/// </summary>
|
||||
public sealed class TagConfigJsonTests
|
||||
{
|
||||
/// <summary>Sample enum for the reader matrix (public so it can be an <c>[InlineData]</c> arg).</summary>
|
||||
public enum Sample { Alpha, Beta, Gamma }
|
||||
|
||||
private static JsonElement Root(string json) => JsonDocument.Parse(json).RootElement;
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"k\":\"Beta\"}", JsonEnumRead.Valid, Sample.Beta)]
|
||||
[InlineData("{\"k\":\"beta\"}", JsonEnumRead.Valid, Sample.Beta)] // case-insensitive
|
||||
[InlineData("{\"k\":\"GAMMA\"}", JsonEnumRead.Valid, Sample.Gamma)]
|
||||
[InlineData("{\"k\":\"Deltaa\"}", JsonEnumRead.Invalid, default(Sample))] // typo → Invalid
|
||||
[InlineData("{}", JsonEnumRead.Absent, default(Sample))] // absent
|
||||
[InlineData("{\"k\":123}", JsonEnumRead.Absent, default(Sample))] // non-string → Absent (lenient)
|
||||
[InlineData("{\"k\":null}", JsonEnumRead.Absent, default(Sample))]
|
||||
public void TryReadEnum_matrix(string json, JsonEnumRead expected, Sample expectedValue)
|
||||
{
|
||||
var outcome = TagConfigJson.TryReadEnum<Sample>(Root(json), "k", out var v);
|
||||
outcome.ShouldBe(expected);
|
||||
v.ShouldBe(expectedValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"k\":\"Gamma\"}", Sample.Gamma)] // valid → parsed
|
||||
[InlineData("{\"k\":\"typo\"}", Sample.Alpha)] // invalid → fallback
|
||||
[InlineData("{}", Sample.Alpha)] // absent → fallback
|
||||
[InlineData("{\"k\":42}", Sample.Alpha)] // non-string → fallback
|
||||
public void ReadEnumOrDefault_is_lenient(string json, Sample expected)
|
||||
{
|
||||
TagConfigJson.ReadEnumOrDefault(Root(json), "k", Sample.Alpha).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"writable\":false}", true, false)] // explicit false → false
|
||||
[InlineData("{\"writable\":true}", true, true)] // explicit true → default
|
||||
[InlineData("{}", true, true)] // absent → default
|
||||
[InlineData("{\"writable\":\"no\"}", true, true)] // non-bool → default
|
||||
[InlineData("{}", false, false)] // absent, default false
|
||||
[InlineData("{\"writable\":false}", false, false)] // explicit false, default false
|
||||
public void ReadWritable_explicit_false_only(string json, bool dflt, bool expected)
|
||||
{
|
||||
TagConfigJson.ReadWritable(Root(json), dflt).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeInvalidEnum_names_field_value_and_valid_members()
|
||||
{
|
||||
var msg = TagConfigJson.DescribeInvalidEnum<Sample>(Root("{\"dataType\":\"Betaa\"}"), "dataType");
|
||||
msg.ShouldNotBeNull();
|
||||
msg.ShouldContain("Betaa");
|
||||
msg.ShouldContain("dataType");
|
||||
msg.ShouldContain("Alpha");
|
||||
msg.ShouldContain("Beta");
|
||||
msg.ShouldContain("Gamma");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"dataType\":\"Beta\"}")] // valid → null
|
||||
[InlineData("{}")] // absent → null
|
||||
[InlineData("{\"dataType\":5}")] // non-string → null
|
||||
public void DescribeInvalidEnum_null_when_absent_or_valid(string json)
|
||||
{
|
||||
TagConfigJson.DescribeInvalidEnum<Sample>(Root(json), "dataType").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Characterization net (R2-11) pinning <see cref="EquipmentNodeWalker.ExtractFullName"/>'s
|
||||
/// raw-blob-fallback semantics across the golden TagConfig corpus BEFORE the parser is delegated to
|
||||
/// <c>TagConfigIntent.Parse</c>. The walker's FullName copy falls back to the raw blob whenever the
|
||||
/// input is not a JSON object carrying a string <c>FullName</c> (blank / non-object / malformed /
|
||||
/// absent / non-string). Whitespace + non-string-property values are returned verbatim (not trimmed).
|
||||
/// Must be green on the unmodified tree and stay green through the swap.
|
||||
/// </summary>
|
||||
public sealed class EquipmentNodeWalkerFullNameCorpusTests
|
||||
{
|
||||
[Theory]
|
||||
// object with a string FullName → the FullName string (verbatim, incl. whitespace)
|
||||
[InlineData("{\"FullName\":\"X.Y\"}", "X.Y")]
|
||||
[InlineData("{\"FullName\":\" \"}", " ")]
|
||||
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5}", "A.B")]
|
||||
// blank → raw blob (IsNullOrWhiteSpace short-circuit)
|
||||
[InlineData(" ", " ")]
|
||||
// non-object root → raw blob
|
||||
[InlineData("[1,2]", "[1,2]")]
|
||||
// malformed JSON → raw blob
|
||||
[InlineData("not json {", "not json {")]
|
||||
// object, FullName absent → raw blob
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":5}", "{\"region\":\"HoldingRegisters\",\"address\":5}")]
|
||||
// object, FullName present but non-string → raw blob
|
||||
[InlineData("{\"FullName\":123}", "{\"FullName\":123}")]
|
||||
public void ExtractFullName_falls_back_to_raw_blob_except_for_string_FullName(string tagConfig, string expected)
|
||||
{
|
||||
EquipmentNodeWalker.ExtractFullName(tagConfig).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user