feat(adminui): B2-WP5 CSV tag import/export + review grid
Staged CSV tag import (upload -> parse -> review grid with per-row verdicts -> all-or-nothing commit) and export for the /raw tree Device/TagGroup. - CsvColumnMap: per-driver typed column <-> TagConfig JSON maps (model-backed for the 7 typed drivers, raw-key for Galaxy/Calculation) + the common column dictionary; typed columns merge OVER the TagConfigJson fallback (typed wins). - RawTagCsvMapper: pure parse (review rows + verdicts + typed/fallback provenance + intra-batch dup detection) and export (residual TagConfigJson) over the T0-2 CsvParser/CsvWriter. - RawTagCsvExportReader: DbContext-backed read helper (no IRawTreeService extension) enumerating a device's tags + reconstructing group paths. - RawCsvImportModal + RawCsvExportModal (data-URI download). - Reflection guard test (typed column -> real model property + batch-plan dictionary parity) + export->import round-trip property test. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user