feat(adminui): driver-agnostic isArray/arrayLength Tag-modal control

This commit is contained in:
Joseph Doherty
2026-06-16 21:50:27 -04:00
parent eb8a8dc19d
commit c2006dfb57
3 changed files with 321 additions and 0 deletions
@@ -0,0 +1,177 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Unit tests for the driver-agnostic <see cref="TagArrayConfig"/> merge helper — the pure seam the
/// TagModal uses to read/write the root <c>isArray</c> / <c>arrayLength</c> array keys on the canonical
/// TagConfig JSON WITHOUT disturbing the driver's own (typed-editor) fields. Both this helper and every
/// typed editor preserve unknown keys, so they compose over the same JSON blob. Mirrors
/// <see cref="TagHistorizeConfigTests"/>.
/// </summary>
public sealed class TagArrayConfigTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
[InlineData("not json")]
public void Read_returns_defaults_for_empty_or_malformed(string? json)
{
var a = TagArrayConfig.Read(json);
a.IsArray.ShouldBeFalse();
a.ArrayLength.ShouldBeNull();
}
[Fact]
public void Read_reads_both_keys()
{
var a = TagArrayConfig.Read("""{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}""");
a.IsArray.ShouldBeTrue();
a.ArrayLength.ShouldBe(8u);
}
[Fact]
public void Set_true_with_length_writes_both_camelCase_keys()
{
var json = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 8);
json.ShouldContain("\"isArray\":true");
json.ShouldContain("\"arrayLength\":8");
// The driver field is untouched.
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
}
[Fact]
public void Set_true_round_trips_through_Read()
{
var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: 16);
var a = TagArrayConfig.Read(json);
a.IsArray.ShouldBeTrue();
a.ArrayLength.ShouldBe(16u);
}
[Fact]
public void Set_false_removes_both_keys()
{
var json = TagArrayConfig.Set(
"""{"FullName":"ns=2;s=X","isArray":true,"arrayLength":8}""",
isArray: false, arrayLength: 8);
json.ShouldNotContain("isArray");
json.ShouldNotContain("arrayLength");
json.ShouldContain("\"FullName\":\"ns=2;s=X\"");
}
[Fact]
public void Set_false_removes_orphan_arrayLength()
{
// Clearing isArray must not leave an orphan arrayLength behind.
var json = TagArrayConfig.Set(
"""{"isArray":true,"arrayLength":4}""",
isArray: false, arrayLength: 4);
json.ShouldNotContain("arrayLength");
json.ShouldNotContain("isArray");
}
[Fact]
public void Set_true_null_length_drops_arrayLength_key()
{
// isArray on but no length yet (pre-validation authoring state): emit isArray, no arrayLength.
var json = TagArrayConfig.Set("{}", isArray: true, arrayLength: null);
json.ShouldContain("\"isArray\":true");
json.ShouldNotContain("arrayLength");
}
[Fact]
public void Set_preserves_driver_specific_unknown_keys()
{
var json = TagArrayConfig.Set(
"""{"register":40001,"scale":0.1,"nested":{"a":1}}""",
isArray: true, arrayLength: 32);
json.ShouldContain("register");
json.ShouldContain("40001");
json.ShouldContain("scale");
json.ShouldContain("0.1");
json.ShouldContain("nested");
json.ShouldContain("\"isArray\":true");
json.ShouldContain("\"arrayLength\":32");
}
[Fact]
public void Set_on_malformed_input_starts_from_empty_object()
{
var json = TagArrayConfig.Set("not json", isArray: true, arrayLength: 2);
json.ShouldContain("\"isArray\":true");
json.ShouldContain("\"arrayLength\":2");
json.ShouldStartWith("{");
json.ShouldEndWith("}");
}
[Fact]
public void Validate_rejects_array_with_null_length()
{
TagArrayConfig.Validate(isArray: true, arrayLength: null).ShouldNotBeNull();
}
[Fact]
public void Validate_rejects_array_with_zero_length()
{
TagArrayConfig.Validate(isArray: true, arrayLength: 0).ShouldNotBeNull();
}
[Fact]
public void Validate_accepts_array_with_positive_length()
{
TagArrayConfig.Validate(isArray: true, arrayLength: 8).ShouldBeNull();
}
[Fact]
public void Validate_accepts_non_array_regardless_of_length()
{
TagArrayConfig.Validate(isArray: false, arrayLength: null).ShouldBeNull();
TagArrayConfig.Validate(isArray: false, arrayLength: 0).ShouldBeNull();
}
/// <summary>
/// Full seam: the array helper and a driver-typed editor compose over the same canonical TagConfig
/// blob without clobbering each other. Setting the array keys, round-tripping through the OpcUaClient
/// typed editor (FromJson → ToJson), then reading the array keys back recovers them intact — and the
/// editor's own FullName field survives the array merge.
/// </summary>
[Fact]
public void Array_keys_survive_a_typed_editor_round_trip()
{
var withArray = TagArrayConfig.Set("""{"FullName":"ns=2;s=X"}""", isArray: true, arrayLength: 12);
var afterEditor = OpcUaClientTagConfigModel.FromJson(withArray).ToJson();
var a = TagArrayConfig.Read(afterEditor);
a.IsArray.ShouldBeTrue();
a.ArrayLength.ShouldBe(12u);
OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
}
/// <summary>The array helper and the historize helper compose over the same blob (both preserve
/// unknown keys), so a tag can carry both array and history intent simultaneously.</summary>
[Fact]
public void Array_and_historize_keys_compose()
{
var withHistory = TagHistorizeConfig.Set("""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
var withBoth = TagArrayConfig.Set(withHistory, isArray: true, arrayLength: 4);
TagArrayConfig.Read(withBoth).ArrayLength.ShouldBe(4u);
TagHistorizeConfig.Read(withBoth).IsHistorized.ShouldBeTrue();
TagHistorizeConfig.Read(withBoth).HistorianTagname.ShouldBe("TN");
}
}