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);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbCip equipment-tag parser: lenient runtime freeze,
|
||||
/// <c>Inspect</c> reports the typo, and the already-honoured <c>writable</c> key stays bit-identical through
|
||||
/// the shared reader swap.</summary>
|
||||
public sealed class AbCipEquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_DInt()
|
||||
{
|
||||
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("DIntt");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured_bit_identical()
|
||||
{
|
||||
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>R2-11 (05/CONV-2) strictness surface for the AbLegacy equipment-tag parser: lenient runtime
|
||||
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
|
||||
public sealed class AbLegacyEquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_Int()
|
||||
{
|
||||
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Intt\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
AbLegacyEquipmentTagParser.Inspect("{\"address\":\"N7:0\",\"dataType\":\"Int\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
AbLegacyEquipmentTagParser.TryParse("{\"address\":\"N7:0\",\"dataType\":\"Int\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/CONV-2 + 05/UNDER-1) strictness surface for the FOCAS equipment-tag parser: lenient
|
||||
/// runtime freeze on <c>dataType</c>, forced read-only (<c>Writable == false</c> regardless of any
|
||||
/// authored <c>writable:true</c>), and <c>Inspect</c> reporting the typo + the write-request.
|
||||
/// </summary>
|
||||
public sealed class FocasEquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_Int32()
|
||||
{
|
||||
FocasEquipmentTagParser.TryParse("{\"address\":\"D0100\",\"dataType\":\"Int322\"}", 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/CONV-2): the Modbus probe parses the SAME factory DTO shape the driver factory parses, so
|
||||
/// <c>timeoutMs</c> binds identically instead of being silently dropped (it never bound to the runtime
|
||||
/// <c>ModbusDriverOptions.Timeout</c> TimeSpan). The OpcUaClient probe/factory parse-parity rule.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverProbeParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Config_timeoutMs_binds_as_the_effective_probe_timeout()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":3,\"timeoutMs\":250}",
|
||||
fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
error.ShouldBeNull();
|
||||
target.ShouldNotBeNull();
|
||||
target!.Value.Host.ShouldBe("10.0.0.5");
|
||||
target.Value.Port.ShouldBe(502);
|
||||
target.Value.UnitId.ShouldBe((byte)3);
|
||||
target.Value.Timeout.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absent_timeoutMs_falls_back_to_the_caller_timeout()
|
||||
{
|
||||
var (target, _) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"host\":\"10.0.0.5\",\"port\":502}", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
target!.Value.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
target.Value.UnitId.ShouldBe((byte)1); // DTO default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_host_is_an_error()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"{\"port\":0}", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
target.ShouldBeNull();
|
||||
error.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_json_is_an_error()
|
||||
{
|
||||
var (target, error) = ModbusDriverProbe.TryParseProbeTarget(
|
||||
"not valid json {{", fallbackTimeout: TimeSpan.FromSeconds(5));
|
||||
target.ShouldBeNull();
|
||||
error.ShouldContain("invalid");
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/CONV-2) strictness surface for the Modbus equipment-tag parser: the runtime stays lenient
|
||||
/// (freeze), the new <c>Inspect</c> reports the silently-defaulted typo, and the <c>writable</c> key is
|
||||
/// now honoured (default true).
|
||||
/// </summary>
|
||||
public sealed class ModbusEquipmentTagParserStrictnessTests
|
||||
{
|
||||
// ---- Phase-A behaviour freeze: a typo'd enum still silently defaults ----
|
||||
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_Int16()
|
||||
{
|
||||
ModbusEquipmentTagParser.TryParse(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(ModbusDataType.Int16);
|
||||
}
|
||||
|
||||
// ---- Inspect reports the typo ----
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType_with_field_and_valid_values()
|
||||
{
|
||||
var warnings = ModbusEquipmentTagParser.Inspect(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt16");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
warnings[0].ShouldContain("Int16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
ModbusEquipmentTagParser.Inspect(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_malformed_json_warns_unresolvable()
|
||||
{
|
||||
ModbusEquipmentTagParser.Inspect("{not json").ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
// ---- writable now honoured ----
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
ModbusEquipmentTagParser.TryParse(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
ModbusEquipmentTagParser.TryParse(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>R2-11 (05/CONV-2) strictness surface for the S7 equipment-tag parser: lenient runtime freeze,
|
||||
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
|
||||
public sealed class S7EquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_Int16()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(S7DataType.Int16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt16");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>R2-11 (05/CONV-2) strictness surface for the TwinCAT equipment-tag parser: lenient runtime
|
||||
/// freeze, <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
|
||||
public sealed class TwinCATEquipmentTagParserStrictnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void Freeze_typo_dataType_still_defaults_to_DInt()
|
||||
{
|
||||
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.DataType.ShouldBe(TwinCATDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_reports_typo_dataType()
|
||||
{
|
||||
var warnings = TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("DIntt");
|
||||
warnings[0].ShouldContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_clean_config_has_no_warnings()
|
||||
{
|
||||
TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_false_is_honoured()
|
||||
{
|
||||
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_absent_defaults_to_true()
|
||||
{
|
||||
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/UNDER-6): the new optional <c>writable</c> key round-trips through the driver-typed tag
|
||||
/// config models — a value the editors' new checkbox authors. Absent stays absent (so existing configs
|
||||
/// are untouched); an explicit <c>false</c> round-trips; unknown keys are preserved.
|
||||
/// </summary>
|
||||
public sealed class TagConfigModelWritableTests
|
||||
{
|
||||
[Fact]
|
||||
public void Modbus_absent_writable_stays_omitted()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}");
|
||||
m.Writable.ShouldBeNull();
|
||||
m.ToJson().ShouldNotContain("writable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modbus_explicit_false_round_trips()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false}");
|
||||
m.Writable.ShouldBe(false);
|
||||
var json = ModbusTagConfigModel.FromJson(m.ToJson());
|
||||
json.Writable.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modbus_setting_writable_true_writes_the_key()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{\"address\":1}");
|
||||
m.Writable = true;
|
||||
ModbusTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writable_preserves_unknown_keys()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{\"address\":1,\"writable\":false,\"foo\":\"bar\"}");
|
||||
m.ToJson().ShouldContain("foo");
|
||||
m.ToJson().ShouldContain("bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S7_writable_round_trips()
|
||||
{
|
||||
var m = S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\",\"writable\":false}");
|
||||
m.Writable.ShouldBe(false);
|
||||
S7TagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbCip_writable_round_trips()
|
||||
{
|
||||
var m = AbCipTagConfigModel.FromJson("{\"tagPath\":\"Motor.Speed\",\"writable\":false}");
|
||||
m.Writable.ShouldBe(false);
|
||||
AbCipTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbLegacy_writable_round_trips()
|
||||
{
|
||||
var m = AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\",\"writable\":false}");
|
||||
m.Writable.ShouldBe(false);
|
||||
AbLegacyTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwinCAT_writable_round_trips()
|
||||
{
|
||||
var m = TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\",\"writable\":false}");
|
||||
m.Writable.ShouldBe(false);
|
||||
TwinCATTagConfigModel.FromJson(m.ToJson()).Writable.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Absent_writable_omitted_across_models()
|
||||
{
|
||||
ModbusTagConfigModel.FromJson("{\"address\":1}").ToJson().ShouldNotContain("writable");
|
||||
S7TagConfigModel.FromJson("{\"address\":\"DB1.DBW0\"}").ToJson().ShouldNotContain("writable");
|
||||
AbCipTagConfigModel.FromJson("{\"tagPath\":\"M.S\"}").ToJson().ShouldNotContain("writable");
|
||||
AbLegacyTagConfigModel.FromJson("{\"address\":\"N7:0\"}").ToJson().ShouldNotContain("writable");
|
||||
TwinCATTagConfigModel.FromJson("{\"symbolPath\":\"MAIN.x\"}").ToJson().ShouldNotContain("writable");
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R2-11 (05/CONV-2) — the deploy gate's TagConfig strictness inspection, exercised THROUGH the actor
|
||||
/// (the F10b/PR#423 lesson: an inspector built but never called is inert). Warn mode (default) accepts
|
||||
/// the deploy with the warning surfaced in the result Message; Error mode rejects the same draft; a
|
||||
/// clean config and an unmapped (Galaxy) tag produce no TagConfig noise.
|
||||
/// </summary>
|
||||
public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private static void Seed(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string driverType, string tagConfig,
|
||||
string tagName = "flow")
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = DraftValidator.DeriveEquipmentId(uuid);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "ns-1", ClusterId = "", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "d-1", ClusterId = "", NamespaceId = "ns-1",
|
||||
Name = "drv", DriverType = driverType, DriverConfig = "{}",
|
||||
});
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "pump-01",
|
||||
DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "PUMP01",
|
||||
});
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "tag-1", DriverInstanceId = "d-1", EquipmentId = equipmentId,
|
||||
Name = tagName, DataType = "Int16", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private const string TypoModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}";
|
||||
private const string CleanModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}";
|
||||
|
||||
[Fact]
|
||||
public void Warn_mode_accepts_and_surfaces_the_warning_in_the_message()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
Seed(dbFactory, "Modbus", TypoModbus);
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(
|
||||
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("Intt16");
|
||||
reply.Message.ShouldContain("tag-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_mode_rejects_the_same_draft()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
Seed(dbFactory, "Modbus", TypoModbus);
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(
|
||||
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("TagConfigInvalid");
|
||||
reply.Message.ShouldContain("Intt16");
|
||||
|
||||
using var verify = dbFactory.CreateDbContext();
|
||||
verify.Deployments.Count().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_config_accepts_with_no_tagconfig_noise()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
Seed(dbFactory, "Modbus", CleanModbus);
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(
|
||||
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Warn));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
(reply.Message is null || !reply.Message.Contains("Intt16")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unmapped_galaxy_tag_is_skipped_even_in_error_mode()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
// Galaxy tag carrying a FullName (passes the DraftValidator Galaxy rule); the inspector has no
|
||||
// Galaxy mapping, so it is skipped regardless of mode.
|
||||
Seed(dbFactory, "GalaxyMxGateway", "{\"FullName\":\"Obj.Attr\",\"dataType\":\"nonsense\"}");
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(
|
||||
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), TagConfigValidationMode.Error));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
||||
ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3)).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>R2-11 (05/CONV-2): the DriverType-dispatched deploy-time TagConfig inspector — known drivers
|
||||
/// route to their parser's Inspect; unmapped drivers (Galaxy/OpcUaClient) skip; keys are case-insensitive.</summary>
|
||||
public sealed class EquipmentTagConfigInspectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Modbus_typo_dispatches_to_one_warning()
|
||||
{
|
||||
var warnings = EquipmentTagConfigInspector.Inspect(
|
||||
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}");
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Intt16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_modbus_config_has_no_warnings()
|
||||
{
|
||||
EquipmentTagConfigInspector.Inspect(
|
||||
"Modbus", "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GalaxyMxGateway")]
|
||||
[InlineData("OpcUaClient")]
|
||||
[InlineData("Unknown")]
|
||||
public void Unmapped_driver_types_are_skipped(string driverType)
|
||||
{
|
||||
EquipmentTagConfigInspector.Inspect(driverType, "{\"dataType\":\"whatever\"}").ShouldBeEmpty();
|
||||
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("modbus")]
|
||||
[InlineData("FOCAS")]
|
||||
[InlineData("focas")]
|
||||
[InlineData("twincat")]
|
||||
public void Driver_type_matching_is_case_insensitive(string driverType)
|
||||
{
|
||||
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Focas_writable_true_dispatches_read_only_warning()
|
||||
{
|
||||
var warnings = EquipmentTagConfigInspector.Inspect(
|
||||
"Focas", "{\"address\":\"D0100\",\"dataType\":\"Int32\",\"writable\":true}");
|
||||
warnings.ShouldContain(w => w.Contains("read-only"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_inputs_are_safe()
|
||||
{
|
||||
EquipmentTagConfigInspector.Inspect(null, "{}").ShouldBeEmpty();
|
||||
EquipmentTagConfigInspector.Inspect("Modbus", null).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+4
-31
@@ -8,8 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
/// Covers follow-up E projection: <see cref="EquipmentNode"/> carries the equipment's
|
||||
/// <c>DriverInstanceId</c> / <c>DeviceId</c> bindings and the resolved <c>DeviceHost</c> (parsed from
|
||||
/// the bound <see cref="Device"/>'s schemaless <c>DeviceConfig</c> JSON via the shared
|
||||
/// <see cref="AddressSpaceComposer.TryExtractDeviceHost"/>). A later task grafts a driver's discovered
|
||||
/// <c>DeviceConfigIntent.TryExtractHost</c>). A later task grafts a driver's discovered
|
||||
/// FixedTree onto a zero-tag equipment and partitions a multi-device driver by host using these.
|
||||
/// <para>The direct host-extraction + normalization unit tests moved to
|
||||
/// <c>Commons.Tests/DeviceConfigIntentTests</c> (R2-11) — this suite keeps the Compose-level projection
|
||||
/// coverage.</para>
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceComposerDeviceHostTests
|
||||
{
|
||||
@@ -75,36 +78,6 @@ public sealed class AddressSpaceComposerDeviceHostTests
|
||||
noHost.DeviceHost.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The shared host extractor normalizes (trim + lower-case) and tolerates every malformed
|
||||
/// shape (blank / non-object / no string HostAddress / blank value / non-JSON) by returning null.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"HostAddress\":\"10.201.31.5:8193\"}", "10.201.31.5:8193")]
|
||||
[InlineData("{\"HostAddress\":\" HOST-A:8193 \"}", "host-a:8193")] // trimmed + lower-cased
|
||||
[InlineData("{\"HostAddress\":\"\"}", null)] // blank value
|
||||
[InlineData("{\"HostAddress\":1234}", null)] // non-string
|
||||
[InlineData("{\"Port\":502}", null)] // absent
|
||||
[InlineData("[]", null)] // non-object root
|
||||
[InlineData("not json", null)] // malformed
|
||||
[InlineData("", null)] // blank
|
||||
public void TryExtractDeviceHost_normalizes_and_tolerates(string? deviceConfig, string? expected)
|
||||
{
|
||||
AddressSpaceComposer.TryExtractDeviceHost(deviceConfig).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The extracted-out shared normalizer (the single source of truth the FixedTree-partition path
|
||||
/// reuses on a driver-discovered device-host folder segment) trims + lower-cases, and is idempotent on an
|
||||
/// already-normalized value — so a segment like <c>" HOST-A:8193 "</c> matches a stored
|
||||
/// <c>"host-a:8193"</c> DeviceHost.</summary>
|
||||
[Theory]
|
||||
[InlineData("10.201.31.5:8193", "10.201.31.5:8193")]
|
||||
[InlineData(" HOST-A:8193 ", "host-a:8193")]
|
||||
[InlineData("host-a:8193", "host-a:8193")] // idempotent
|
||||
[InlineData("H1", "h1")]
|
||||
public void NormalizeDeviceHost_trims_and_lowercases(string raw, string expected)
|
||||
{
|
||||
AddressSpaceComposer.NormalizeDeviceHost(raw).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static Equipment NewEquipment(string id, string? driver, string? device) => new()
|
||||
{
|
||||
EquipmentId = id,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public class 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)]
|
||||
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
|
||||
{
|
||||
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
|
||||
if (!present) { info.ShouldBeNull(); return; }
|
||||
info!.AlarmType.ShouldBe(type);
|
||||
info.Severity.ShouldBe(sev);
|
||||
}
|
||||
|
||||
/// <summary>historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses
|
||||
/// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as
|
||||
/// default-on). Mirrors the scripted-alarm opt-out posture.</summary>
|
||||
[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 ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected)
|
||||
{
|
||||
var info = AddressSpaceComposer.ExtractTagAlarm(cfg);
|
||||
info.ShouldNotBeNull();
|
||||
info!.HistorizeToAveva.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="AddressSpaceComposer.ExtractTagArray"/> parses the optional array intent from a
|
||||
/// tag's <c>TagConfig</c> JSON exactly as <see cref="AddressSpaceComposer.ExtractTagHistorize"/> parses
|
||||
/// the historize intent: the <c>isArray</c> bool (absent / not a bool / non-object root / blank /
|
||||
/// malformed ⇒ <c>false</c>) and the optional <c>arrayLength</c> uint (only honoured when
|
||||
/// <c>isArray</c> is true AND the prop is a JSON number that fits uint; else <c>null</c>). Never
|
||||
/// throws. Also pins the end-to-end <see cref="AddressSpaceComposer.Compose"/> thread-through onto
|
||||
/// <see cref="EquipmentTagPlan.IsArray"/> / <see cref="EquipmentTagPlan.ArrayLength"/>.
|
||||
/// </summary>
|
||||
public class ExtractTagArrayTests
|
||||
{
|
||||
[Theory]
|
||||
// isArray true with an explicit arrayLength.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":16}", true, (uint)16)]
|
||||
// isArray true, no arrayLength ⇒ length null.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true}", true, null)]
|
||||
// Absent isArray ⇒ false (arrayLength ignored even if present).
|
||||
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
|
||||
// arrayLength present but isArray false ⇒ length null (only honoured when the flag is true).
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":false,\"arrayLength\":16}", false, null)]
|
||||
// arrayLength absent-with-flag honoured-as-null when isArray true but no length.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isArray\":true,\"arrayLength\":0}", true, (uint)0)]
|
||||
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
|
||||
[InlineData(null, false, null)]
|
||||
[InlineData("", false, null)]
|
||||
[InlineData("not json {", false, null)]
|
||||
[InlineData("[1,2]", false, null)]
|
||||
// Wrong type for isArray (string, not bool) ⇒ false.
|
||||
[InlineData("{\"isArray\":\"yes\"}", false, null)]
|
||||
// Wrong type for arrayLength (string, not number) ⇒ length null, flag still honoured.
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)]
|
||||
// Negative arrayLength does not fit uint ⇒ length null, flag still honoured.
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)]
|
||||
// Float arrayLength (16.5) is not an exact uint ⇒ TryGetUInt32 rejects it ⇒ length null.
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)]
|
||||
// Overflow arrayLength (uint.MaxValue + 1 = 4294967296) does not fit uint ⇒ length null.
|
||||
[InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)]
|
||||
public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength)
|
||||
{
|
||||
var (isArray, arrayLength) = AddressSpaceComposer.ExtractTagArray(cfg);
|
||||
isArray.ShouldBe(expectedIsArray);
|
||||
arrayLength.ShouldBe(expectedLength);
|
||||
}
|
||||
|
||||
/// <summary>End-to-end: an equipment tag whose TagConfig carries <c>isArray</c>/<c>arrayLength</c>
|
||||
/// surfaces those on its <see cref="EquipmentTagPlan"/> through <see cref="AddressSpaceComposer.Compose"/>,
|
||||
/// exactly as the historize keys thread through.</summary>
|
||||
[Fact]
|
||||
public void Compose_threads_array_keys_onto_equipment_tag_plan()
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
NamespaceId = "ns-eq",
|
||||
ClusterId = "c1",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:eq",
|
||||
};
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-1",
|
||||
ClusterId = "c1",
|
||||
NamespaceId = "ns-eq",
|
||||
Name = "Modbus1",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment
|
||||
{
|
||||
EquipmentId = "eq-1",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "Machine_001",
|
||||
MachineCode = "MACHINE_001",
|
||||
};
|
||||
var arrayTag = new Tag
|
||||
{
|
||||
TagId = "tag-arr",
|
||||
DriverInstanceId = "drv-1",
|
||||
EquipmentId = "eq-1",
|
||||
FolderPath = null,
|
||||
Name = "Buffer",
|
||||
DataType = "Int16",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}",
|
||||
};
|
||||
|
||||
var result = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
new[] { arrayTag }, new[] { ns });
|
||||
|
||||
var tag = result.EquipmentTags.ShouldHaveSingleItem();
|
||||
tag.TagId.ShouldBe("tag-arr");
|
||||
tag.IsArray.ShouldBeTrue();
|
||||
tag.ArrayLength.ShouldBe((uint)16);
|
||||
}
|
||||
|
||||
/// <summary>End-to-end: a scalar equipment tag (no array keys) yields IsArray=false, ArrayLength=null.</summary>
|
||||
[Fact]
|
||||
public void Compose_leaves_scalar_equipment_tag_plan_unflagged()
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
NamespaceId = "ns-eq",
|
||||
ClusterId = "c1",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:eq",
|
||||
};
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-1",
|
||||
ClusterId = "c1",
|
||||
NamespaceId = "ns-eq",
|
||||
Name = "Modbus1",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment
|
||||
{
|
||||
EquipmentId = "eq-1",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "Machine_001",
|
||||
MachineCode = "MACHINE_001",
|
||||
};
|
||||
var scalarTag = new Tag
|
||||
{
|
||||
TagId = "tag-scalar",
|
||||
DriverInstanceId = "drv-1",
|
||||
EquipmentId = "eq-1",
|
||||
FolderPath = null,
|
||||
Name = "Speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"40005\"}",
|
||||
};
|
||||
|
||||
var result = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
new[] { scalarTag }, new[] { ns });
|
||||
|
||||
var tag = result.EquipmentTags.ShouldHaveSingleItem();
|
||||
tag.IsArray.ShouldBeFalse();
|
||||
tag.ArrayLength.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public class ExtractTagHistorizeTests
|
||||
{
|
||||
[Theory]
|
||||
// isHistorized true, no explicit tagname ⇒ tagname null (defaults to FullName later).
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true}", true, null)]
|
||||
// isHistorized true with an explicit historian-tagname override.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\"WW.Tag\"}", true, "WW.Tag")]
|
||||
// Absent isHistorized ⇒ false.
|
||||
[InlineData("{\"FullName\":\"T.A\"}", false, null)]
|
||||
// historianTagname parses independently of the flag.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":false,\"historianTagname\":\"WW.Tag\"}", false, "WW.Tag")]
|
||||
// Blank/whitespace tagname ⇒ null.
|
||||
[InlineData("{\"FullName\":\"T.A\",\"isHistorized\":true,\"historianTagname\":\" \"}", true, null)]
|
||||
// null / empty / malformed-JSON / array-root ⇒ (false, null), never throws.
|
||||
[InlineData(null, false, null)]
|
||||
[InlineData("", false, null)]
|
||||
[InlineData("not json {", false, null)]
|
||||
[InlineData("[1,2]", false, null)]
|
||||
// Wrong type for isHistorized (string, not bool) ⇒ false.
|
||||
[InlineData("{\"isHistorized\":\"yes\"}", false, null)]
|
||||
// Wrong type for historianTagname (number, not string) ⇒ tagname null, flag still honoured.
|
||||
[InlineData("{\"isHistorized\":true,\"historianTagname\":123}", true, null)]
|
||||
public void ExtractTagHistorize_parses_or_returns_defaults(string? cfg, bool expectedHistorized, string? expectedTagname)
|
||||
{
|
||||
var (isHistorized, historianTagname) = AddressSpaceComposer.ExtractTagHistorize(cfg);
|
||||
isHistorized.ShouldBe(expectedHistorized);
|
||||
historianTagname.ShouldBe(expectedTagname);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
/// and the artifact decoder (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>).
|
||||
/// A secondary/follower node decoding a serialized artifact MUST see the same DeviceHost as the
|
||||
/// primary so it grafts FixedTree / partitions multi-device drivers identically. Both sides resolve
|
||||
/// the host through the shared <see cref="AddressSpaceComposer.TryExtractDeviceHost"/> (single source
|
||||
/// the host through the shared <c>DeviceConfigIntent.TryExtractHost</c> (single source
|
||||
/// of truth + identical trim + lower-case normalization).
|
||||
/// </summary>
|
||||
public sealed class DeploymentArtifactDeviceHostParityTests
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
/// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
|
||||
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
|
||||
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
|
||||
/// vs stored "h1" wrinkle proves the SHARED <see cref="AddressSpaceComposer.NormalizeDeviceHost"/> match.</summary>
|
||||
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
|
||||
[Fact]
|
||||
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-seam characterization net for R2-11 (01/C-1 + 01/P-1). Composes one equipment tag per
|
||||
/// <see cref="TagConfigGoldenCorpus"/> blob, serialises the SAME draft to the artifact blob shape,
|
||||
/// decodes it, and asserts the decoded <see cref="EquipmentTagPlan"/> list equals the composer's
|
||||
/// element-wise (positional-record value equality) over the WHOLE corpus. Because both producers parse
|
||||
/// the identical raw <c>TagConfig</c> string, this proves the four byte-parity parse sites agree TODAY,
|
||||
/// and is the permanent regression guard held green through every subsequent seam swap
|
||||
/// (<c>TagConfigIntent.Parse</c> consolidation).
|
||||
/// </summary>
|
||||
public sealed class TagConfigCorpusParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Composer_and_artifact_agree_over_the_whole_TagConfig_corpus()
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
NamespaceId = "ns-eq",
|
||||
ClusterId = "c1",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:eq",
|
||||
};
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-1",
|
||||
ClusterId = "c1",
|
||||
NamespaceId = "ns-eq",
|
||||
Name = "Modbus1",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment
|
||||
{
|
||||
EquipmentId = "eq-1",
|
||||
DriverInstanceId = "drv-1",
|
||||
UnsLineId = "line-1",
|
||||
Name = "Machine_001",
|
||||
MachineCode = "MACHINE_001",
|
||||
};
|
||||
|
||||
var tags = TagConfigGoldenCorpus.Blobs
|
||||
.Select((blob, i) => new Tag
|
||||
{
|
||||
TagId = $"tag-{i:D2}",
|
||||
DriverInstanceId = "drv-1",
|
||||
EquipmentId = "eq-1",
|
||||
FolderPath = null,
|
||||
Name = $"Sig{i:D2}",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = blob,
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// ---- Side 1: the live-edit composer ----
|
||||
var composed = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(), tags, new[] { ns });
|
||||
|
||||
// ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind },
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
|
||||
},
|
||||
Tags = tags.Select(ToSnapshot).ToArray(),
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
decoded.EquipmentTags.Count.ShouldBe(tags.Length);
|
||||
// Full byte-parity: every field, same order (positional-record value equality). A field-by-field
|
||||
// spell-out per blob so a divergence names the offending corpus entry.
|
||||
for (var i = 0; i < composed.EquipmentTags.Count; i++)
|
||||
{
|
||||
var c = composed.EquipmentTags[i];
|
||||
var d = decoded.EquipmentTags.Single(t => t.TagId == c.TagId);
|
||||
d.ShouldBe(c, customMessage: $"corpus blob #{c.TagId} diverged: '{tags.Single(t => t.TagId == c.TagId).TagConfig}'");
|
||||
}
|
||||
decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static object ToSnapshot(Tag t) => new
|
||||
{
|
||||
t.TagId,
|
||||
t.DriverInstanceId,
|
||||
t.EquipmentId,
|
||||
t.Name,
|
||||
t.FolderPath,
|
||||
t.DataType,
|
||||
AccessLevel = (int)t.AccessLevel,
|
||||
t.TagConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// The shared golden corpus of <c>TagConfig</c> JSON blobs used to characterise the cross-driver
|
||||
/// platform-intent parse (FullName / alarm / isHistorized / historianTagname / isArray / arrayLength).
|
||||
/// Every entry is a NON-NULL string (the DB <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a
|
||||
/// non-null TagConfig for a real deploy) so the compose→encode→decode parity test
|
||||
/// (<see cref="TagConfigCorpusParityTests"/>) can round-trip each through both equipment-tag producers
|
||||
/// and assert byte-parity field-by-field. The <c>null</c>-input divergence (composer returns the raw
|
||||
/// null, artifact coalesces to "") is a documented seam handled only at the
|
||||
/// <c>TagConfigIntent.Parse</c> unit level, not through the parity round-trip.
|
||||
/// <para>Covers: happy path, blank, non-object root, malformed JSON, FullName absent/non-string/whitespace,
|
||||
/// alarm object with each field absent/wrong-type, historizeToAveva true/false/non-bool,
|
||||
/// historianTagname whitespace, isArray/arrayLength combinations (incl. negative/overflow/non-number/
|
||||
/// length-without-flag/zero), unknown keys, and mixed platform+driver keys.</para>
|
||||
/// </summary>
|
||||
public static class TagConfigGoldenCorpus
|
||||
{
|
||||
/// <summary>The corpus blobs, index-ordered. The parity test builds one equipment tag per blob.</summary>
|
||||
public static readonly IReadOnlyList<string> Blobs = new[]
|
||||
{
|
||||
// 0 happy path
|
||||
"{\"FullName\":\"X.Y\"}",
|
||||
// 1 blank (whitespace) — FullName falls back to the raw (blank) blob on both seams
|
||||
" ",
|
||||
// 2 non-object root
|
||||
"[1,2]",
|
||||
// 3 malformed JSON
|
||||
"not json {",
|
||||
// 4 FullName absent (driver keys only) — FullName = raw blob
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":5}",
|
||||
// 5 FullName present but non-string — FullName = raw blob
|
||||
"{\"FullName\":123}",
|
||||
// 6 FullName whitespace value (not trimmed)
|
||||
"{\"FullName\":\" \"}",
|
||||
// 7 alarm empty object → defaults (AlarmCondition, 500, historize null)
|
||||
"{\"FullName\":\"A\",\"alarm\":{}}",
|
||||
// 8 alarm fully specified
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
||||
// 9 alarm non-object → no alarm
|
||||
"{\"FullName\":\"A\",\"alarm\":\"oops\"}",
|
||||
// 10 alarm historizeToAveva true
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}",
|
||||
// 11 alarm historizeToAveva false
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}",
|
||||
// 12 alarm historizeToAveva non-bool → null
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}",
|
||||
// 13 alarm severity non-number → 500
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"severity\":\"high\"}}",
|
||||
// 14 alarm alarmType non-string → AlarmCondition
|
||||
"{\"FullName\":\"A\",\"alarm\":{\"alarmType\":123}}",
|
||||
// 15 isHistorized true
|
||||
"{\"FullName\":\"A\",\"isHistorized\":true}",
|
||||
// 16 isHistorized true + explicit tagname
|
||||
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\"P.L.X\"}",
|
||||
// 17 isHistorized false + JSON-null tagname
|
||||
"{\"FullName\":\"A\",\"isHistorized\":false,\"historianTagname\":null}",
|
||||
// 18 historianTagname whitespace → null
|
||||
"{\"FullName\":\"A\",\"isHistorized\":true,\"historianTagname\":\" \"}",
|
||||
// 19 isHistorized non-bool → false
|
||||
"{\"FullName\":\"A\",\"isHistorized\":\"yes\"}",
|
||||
// 20 isArray true + length
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16}",
|
||||
// 21 isArray true, no length
|
||||
"{\"FullName\":\"A\",\"isArray\":true}",
|
||||
// 22 isArray false + length → scalar
|
||||
"{\"FullName\":\"A\",\"isArray\":false,\"arrayLength\":16}",
|
||||
// 23 isArray true, length 0 → true, 0
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":0}",
|
||||
// 24 arrayLength non-number → true, null
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":\"16\"}",
|
||||
// 25 arrayLength negative → true, null
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":-1}",
|
||||
// 26 arrayLength float → true, null
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":16.5}",
|
||||
// 27 arrayLength overflow (uint.MaxValue + 1) → true, null
|
||||
"{\"FullName\":\"A\",\"isArray\":true,\"arrayLength\":4294967296}",
|
||||
// 28 unknown + mixed platform/driver keys
|
||||
"{\"FullName\":\"A.B\",\"region\":\"Coils\",\"address\":5,\"dataType\":\"Int16\",\"isHistorized\":true,\"alarm\":{\"severity\":250},\"foo\":\"bar\"}",
|
||||
// 29 all intents combined
|
||||
"{\"FullName\":\"C.D\",\"isArray\":true,\"arrayLength\":4,\"isHistorized\":true,\"historianTagname\":\"H\",\"alarm\":{\"alarmType\":\"DiscreteAlarm\",\"severity\":900,\"historizeToAveva\":false}}",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user