merge wt/wp5 into v3/batch2-raw-ui (Wave B UI)

This commit is contained in:
Joseph Doherty
2026-07-16 03:27:23 -04:00
8 changed files with 1867 additions and 0 deletions
@@ -0,0 +1,85 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Drift guard for the WP5 CSV column dictionary (<see cref="CsvColumnMap"/>): every typed column of a
/// model-backed driver map must name a real property on that driver's <c><![CDATA[<Driver>TagConfigModel]]></c>,
/// and the registered typed-column set per driver must match the batch-plan column dictionary exactly. A
/// model rename or a column-dictionary change breaks this test rather than silently corrupting import/export.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CsvColumnMapReflectionTests
{
[Fact]
public void Every_model_backed_typed_column_maps_to_a_real_model_property()
{
foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is not null))
{
foreach (var col in map.Columns)
{
col.ModelProperty.ShouldNotBeNull(
$"{map.DriverType} column '{col.Header}' is model-backed but declares no ModelProperty.");
map.ModelType!.GetProperty(col.ModelProperty!)
.ShouldNotBeNull(
$"{map.DriverType} column '{col.Header}' maps to '{col.ModelProperty}', " +
$"which is not a property on {map.ModelType!.Name}.");
}
}
}
[Fact]
public void Model_less_maps_declare_no_model_property()
{
foreach (var map in CsvColumnMap.All.Where(m => m.ModelType is null))
{
foreach (var col in map.Columns)
{
col.ModelProperty.ShouldBeNull(
$"{map.DriverType} column '{col.Header}' is model-less but declares ModelProperty '{col.ModelProperty}'.");
}
}
}
[Theory]
[InlineData(DriverTypeNames.Modbus, "Region", "Address", "ModbusDataType", "ByteOrder", "BitIndex", "StringLength", "Writable")]
[InlineData(DriverTypeNames.S7, "Address", "S7DataType", "StringLength", "Writable")]
[InlineData(DriverTypeNames.AbCip, "TagPath", "AbCipDataType", "Writable")]
[InlineData(DriverTypeNames.AbLegacy, "Address", "AbLegacyDataType", "Writable")]
[InlineData(DriverTypeNames.TwinCAT, "SymbolPath", "TwinCATDataType", "Writable")]
[InlineData(DriverTypeNames.FOCAS, "Address", "FocasDataType")]
[InlineData(DriverTypeNames.OpcUaClient, "NodeId")]
[InlineData(DriverTypeNames.Galaxy, "AttributeRef")]
[InlineData(CsvColumnMap.CalculationDriverType, "ScriptId", "ChangeTriggered", "TimerIntervalMs")]
public void Registered_typed_columns_match_the_batch_plan_dictionary(string driverType, params string[] expected)
{
var map = CsvColumnMap.Resolve(driverType);
map.ShouldNotBeNull($"No CSV map registered for driver '{driverType}'.");
map!.Headers.ShouldBe(expected);
}
[Fact]
public void HeadersFor_puts_typed_columns_between_common_leading_and_the_TagConfigJson_fallback()
{
var headers = CsvColumnMap.HeadersFor(DriverTypeNames.Modbus);
// Leading common columns come first, in order.
headers.Take(CsvColumnMap.CommonLeadingColumns.Count).ShouldBe(CsvColumnMap.CommonLeadingColumns);
// The typed Modbus columns come next.
headers.Skip(CsvColumnMap.CommonLeadingColumns.Count).Take(7)
.ShouldBe(CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.Headers);
// TagConfigJson is always the trailing fallback.
headers[^1].ShouldBe(CsvColumnMap.TagConfigJsonColumn);
}
[Fact]
public void Unknown_driver_headers_are_the_common_set_only()
{
CsvColumnMap.Resolve("NoSuchDriver").ShouldBeNull();
CsvColumnMap.HeadersFor("NoSuchDriver").ShouldBe(CsvColumnMap.CommonColumns);
}
}
@@ -0,0 +1,233 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Property test for the WP5 CSV export→import round-trip: <see cref="RawTagCsvMapper.Export"/> a device's
/// tags then re-parse the produced CSV with <see cref="RawTagCsvMapper.Parse"/> and assert the reconstructed
/// tag set is identical (name / group path / data type / access / write flag / poll group and a semantically
/// equal <c>TagConfig</c>). Exercises typed driver columns, the historize/array/alarm flag columns, the
/// residual <c>TagConfigJson</c> fallback (typed-wins), and the model-less (Galaxy) raw-key map.
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTagCsvRoundTripTests
{
[Fact]
public void Modbus_device_round_trips_typed_columns_flags_and_residual()
{
// A rich Modbus config: every typed column + historize + array + alarm + a residual ("scale")
// key the TagConfigJson fallback must carry.
var richTag = new RawCsvExportTag(
TagGroupPath: "Bank1/Analog",
Name: "flow-01",
DataType: "Float",
AccessLevel: TagAccessLevel.ReadWrite,
WriteIdempotent: true,
PollGroupId: "fast",
TagConfig: """
{"region":"InputRegisters","address":10,"dataType":"Float32","byteOrder":"WordSwap",
"bitIndex":0,"stringLength":0,"writable":false,"scale":3.5,
"isHistorized":true,"historianTagname":"MyHist","isArray":true,"arrayLength":4,
"alarm":{"alarmType":"LimitAlarm","severity":800,"historizeToAveva":false}}
""");
// A device-root tag (null group path) with only typed columns + no flags.
var plainTag = new RawCsvExportTag(
TagGroupPath: null,
Name: "coil-3",
DataType: "Boolean",
AccessLevel: TagAccessLevel.Read,
WriteIdempotent: false,
PollGroupId: null,
TagConfig: ModbusModel("Coils", 3, "Bool", "BigEndian", writable: null));
AssertRoundTrip(DriverTypeNames.Modbus, new[] { richTag, plainTag });
}
[Fact]
public void OpcUaClient_and_Galaxy_maps_round_trip()
{
var opc = new RawCsvExportTag(
"Line/Motors", "m1", "Double", TagAccessLevel.ReadWrite, false, null,
"""{"nodeId":"nsu=urn:plc;s=Motor1.Speed","isHistorized":true}""");
AssertRoundTrip(DriverTypeNames.OpcUaClient, new[] { opc });
var galaxy = new RawCsvExportTag(
null, "Reactor_Temp", "Float", TagAccessLevel.Read, false, null,
"""{"FullName":"Reactor_001.Temp","historianTagname":"Reactor.Temp"}""");
AssertRoundTrip(DriverTypeNames.Galaxy, new[] { galaxy });
}
[Fact]
public void Export_header_matches_the_driver_column_dictionary()
{
var csv = RawTagCsvMapper.Export(DriverTypeNames.S7, Array.Empty<RawCsvExportTag>());
var rows = CsvParser.Parse(csv);
rows.ShouldHaveSingleItem(); // header only
rows[0].ShouldBe(CsvColumnMap.HeadersFor(DriverTypeNames.S7).ToArray());
}
[Fact]
public void A_typed_column_wins_over_a_conflicting_TagConfigJson_key()
{
// Base carries address="DB1.DBW99"; the typed Address column carries "DB1.DBW5" — the typed value
// must win, and the review row must flag the override. (S7 Address is a string field.)
var csv = string.Join("\r\n",
"Name,DataType,Address,S7DataType,TagConfigJson",
"""flow,Int16,DB1.DBW5,Int16,"{""address"":""DB1.DBW99""}" """.Trim());
var result = RawTagCsvMapper.Parse(DriverTypeNames.S7, csv);
result.FatalError.ShouldBeNull();
var row = result.Rows.ShouldHaveSingleItem();
row.Ok.ShouldBeTrue();
row.TypedOverrodeFallback.ShouldContain("Address");
var o = JsonNode.Parse(row.ImportRow!.Tag.TagConfig)!.AsObject();
o["address"]!.GetValue<string>().ShouldBe("DB1.DBW5");
}
[Fact]
public void A_bad_enum_row_is_flagged_and_blocks_commit()
{
var csv = string.Join("\r\n",
"Name,DataType,Region,Address,ModbusDataType",
"t1,Int16,NotARegion,0,Int16");
var result = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
var row = result.Rows.ShouldHaveSingleItem();
row.Ok.ShouldBeFalse();
row.Error!.ShouldContain("Region");
result.CanCommit.ShouldBeFalse();
}
[Fact]
public void Duplicate_name_in_the_same_group_is_flagged_on_the_second_row()
{
var csv = string.Join("\r\n",
"Name,DataType,TagGroupPath,NodeId",
"dup,Int32,G1,ns=2;s=A",
"dup,Int32,G1,ns=2;s=B");
var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv);
result.Rows[0].Ok.ShouldBeTrue();
result.Rows[1].Ok.ShouldBeFalse();
result.Rows[1].Error!.ShouldContain("Duplicate");
result.CanCommit.ShouldBeFalse();
}
[Fact]
public void Group_path_prefix_is_prepended_to_each_row()
{
var csv = string.Join("\r\n",
"Name,DataType,TagGroupPath,NodeId",
"a,Int32,Sub,ns=2;s=A",
"b,Int32,,ns=2;s=B");
var result = RawTagCsvMapper.Parse(DriverTypeNames.OpcUaClient, csv, groupPathPrefix: "Root");
result.Rows[0].ImportRow!.TagGroupPath.ShouldBe("Root/Sub");
result.Rows[1].ImportRow!.TagGroupPath.ShouldBe("Root");
}
// -------------------------------------------------------------------------------------- helpers
private static void AssertRoundTrip(string driverType, IReadOnlyList<RawCsvExportTag> tags)
{
var csv = RawTagCsvMapper.Export(driverType, tags);
var parsed = RawTagCsvMapper.Parse(driverType, csv);
parsed.FatalError.ShouldBeNull();
parsed.CanCommit.ShouldBeTrue($"every re-imported row should be valid; errors: " +
string.Join(" | ", parsed.Rows.Where(r => !r.Ok).Select(r => r.Error)));
var imported = parsed.ToImportRows();
imported.Count.ShouldBe(tags.Count);
foreach (var original in tags)
{
var match = imported.SingleOrDefault(r => r.Tag.Name == original.Name);
match.ShouldNotBeNull($"tag '{original.Name}' vanished across the round-trip.");
match!.TagGroupPath.ShouldBe(original.TagGroupPath, $"group path drift for '{original.Name}'.");
match.Tag.DataType.ShouldBe(original.DataType);
match.Tag.AccessLevel.ShouldBe(original.AccessLevel);
match.Tag.WriteIdempotent.ShouldBe(original.WriteIdempotent);
match.Tag.PollGroupId.ShouldBe(original.PollGroupId);
Canonical(match.Tag.TagConfig).ShouldBe(Canonical(original.TagConfig),
$"TagConfig drift for '{original.Name}'.");
}
}
// Canonical Modbus config via the real model, so the "expected" already carries the model's full key set.
private static string ModbusModel(string region, int address, string dataType, string byteOrder, bool? writable)
{
var m = ModbusTagConfigModel.FromJson("{}");
m.Region = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion>(region);
m.Address = address;
m.DataType = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType>(dataType);
m.ByteOrder = Enum.Parse<ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusByteOrder>(byteOrder);
m.Writable = writable;
return m.ToJson();
}
// Order-independent, numeric-normalised JSON canonicaliser for semantic equality.
private static string Canonical(string json)
{
var node = JsonNode.Parse(json);
return Normalize(node)?.ToJsonString() ?? "null";
}
private static JsonNode? Normalize(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
{
var sorted = new JsonObject();
foreach (var key in obj.Select(kvp => kvp.Key).OrderBy(k => k, StringComparer.Ordinal))
{
sorted[key] = Normalize(obj[key]?.DeepClone());
}
return sorted;
}
case JsonArray arr:
{
var outArr = new JsonArray();
foreach (var item in arr)
{
outArr.Add(Normalize(item?.DeepClone()));
}
return outArr;
}
case JsonValue val:
{
// Collapse numeric representations (int vs uint vs double 4 vs 4.0) to a canonical form.
if (val.GetValueKind() == JsonValueKind.Number &&
double.TryParse(val.ToJsonString(), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var d))
{
return JsonValue.Create(d);
}
return val;
}
default:
return node;
}
}
}