refactor(opcuaserver): composer parses TagConfig once per tag via TagConfigIntent (R2-11, 01/P-1)
Deletes the composer's ExtractTagFullName/Alarm/Historize/Array statics and (co-located to keep the build green, per plan T5) the three OpcUaServer.Tests ExtractTag* suites whose tables now live in Commons.Tests/TagConfigIntentTests. T10 is the grep-sweep verification.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "pending", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "pending", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "pending", "blockedBy": ["T5"] },
|
||||
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "pending", "blockedBy": ["T2", "T3"] },
|
||||
|
||||
@@ -415,8 +415,11 @@ public static class AddressSpaceComposer
|
||||
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Select(t =>
|
||||
{
|
||||
var (isHistorized, historianTagname) = ExtractTagHistorize(t.TagConfig);
|
||||
var (isArray, arrayLength) = ExtractTagArray(t.TagConfig);
|
||||
// Parse the schemaless TagConfig blob ONCE per tag (01/P-1) via the shared byte-parity
|
||||
// authority (01/C-1). TagConfigIntent.Parse is the SINGLE SOURCE OF TRUTH the
|
||||
// artifact-decode seam (DeploymentArtifact), the draft gate (DraftValidator), and the
|
||||
// walker all consume — so the live-compose and artifact-decode plans stay byte-equal.
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
return new EquipmentTagPlan(
|
||||
TagId: t.TagId,
|
||||
EquipmentId: t.EquipmentId!,
|
||||
@@ -424,13 +427,13 @@ public static class AddressSpaceComposer
|
||||
FolderPath: t.FolderPath ?? string.Empty,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
FullName: ExtractTagFullName(t.TagConfig),
|
||||
FullName: intent.FullName,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: ExtractTagAlarm(t.TagConfig),
|
||||
IsHistorized: isHistorized,
|
||||
HistorianTagname: historianTagname,
|
||||
IsArray: isArray,
|
||||
ArrayLength: arrayLength);
|
||||
Alarm: intent.Alarm is { } a ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) : null,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -523,33 +526,6 @@ public static class AddressSpaceComposer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
|
||||
/// <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
|
||||
/// driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
|
||||
/// <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
|
||||
/// driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
|
||||
/// Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The tag's wire-level address JSON.</param>
|
||||
/// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
|
||||
private static string ExtractTagFullName(string tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through to raw blob */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a <see cref="Device"/>'s connection host from its schemaless <c>DeviceConfig</c> JSON:
|
||||
/// the top-level <c>"HostAddress"</c> string (e.g. <c>"10.201.31.5:8193"</c>) — the same value a
|
||||
@@ -593,95 +569,4 @@ public static class AddressSpaceComposer
|
||||
/// <returns>The normalized host (trimmed + lower-cased).</returns>
|
||||
public static string NormalizeDeviceHost(string host) => host.Trim().ToLowerInvariant();
|
||||
|
||||
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
|
||||
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
|
||||
// durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional server-side HistoryRead intent from a tag's <c>TagConfig</c> JSON:
|
||||
/// the <c>isHistorized</c> bool (absent / not a bool / non-object root / blank / malformed ⇒
|
||||
/// <c>false</c>) and the optional <c>historianTagname</c> string override (absent / not a string /
|
||||
/// whitespace-or-empty ⇒ <c>null</c>, meaning the historian tagname defaults to the tag's FullName,
|
||||
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
|
||||
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
|
||||
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isHistorized = doc.RootElement.TryGetProperty("isHistorized", out var hEl)
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
string? tagname = null;
|
||||
if (doc.RootElement.TryGetProperty("historianTagname", out var nEl)
|
||||
&& nEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = nEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw)) tagname = raw;
|
||||
}
|
||||
return (isHistorized, tagname);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
|
||||
/// <summary>Parses the optional array intent from a tag's <c>TagConfig</c> JSON: the <c>isArray</c>
|
||||
/// bool (absent / not a bool / non-object root / blank / malformed ⇒ <c>false</c>) and the optional
|
||||
/// <c>arrayLength</c> uint. The length is honoured ONLY when <c>isArray</c> is true AND the prop is a
|
||||
/// JSON number that fits <c>uint</c> (else <c>null</c> ⇒ unbounded 1-D array,
|
||||
/// <c>ArrayDimensions=[0]</c> at materialisation). Mirrors
|
||||
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
|
||||
/// tolerance. Never throws. The artifact-decode side
|
||||
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
|
||||
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
|
||||
/// <returns>The parsed array flag and optional array length.</returns>
|
||||
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null);
|
||||
var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl)
|
||||
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||
&& aEl.GetBoolean();
|
||||
uint? arrayLength = null;
|
||||
if (isArray
|
||||
&& doc.RootElement.TryGetProperty("arrayLength", out var lEl)
|
||||
&& lEl.ValueKind == JsonValueKind.Number
|
||||
&& lEl.TryGetUInt32(out var len))
|
||||
{
|
||||
arrayLength = len;
|
||||
}
|
||||
return (isArray, arrayLength);
|
||||
}
|
||||
catch (JsonException) { return (false, null); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user