Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvColumnMapReflectionTests.cs
T
Joseph Doherty 59ea02d971 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
2026-07-16 03:19:14 -04:00

86 lines
3.8 KiB
C#

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);
}
}