Auto: abcip-2.4 — CSV tag import/export

CsvTagImporter / CsvTagExporter parse and emit Kepware-format AB CIP tag
CSVs (Tag Name, Address, Data Type, Respect Data Type, Client Access,
Scan Rate, Description, Scaling). Import maps Tag Name → AbCipTagDefinition.Name,
Address → TagPath, Data Type → DataType, Description → Description,
Client Access → Writable. Skips blank rows + ;/# section markers; honours
column reordering via header lookup; RFC-4180-ish quoting.

CsvImports collection on AbCipDriverOptions mirrors L5kImports/L5xImports
and is consumed by InitializeAsync (declared > L5K > L5X > CSV precedence).

CLI tag-export command dumps the merged tag table from a driver-options JSON
to a Kepware CSV — runs the same import-merge precedence the driver uses but
without contacting any PLC.

Tests cover R/W mapping, blank-row skip, quoted comma, escaped quote, name
prefix, unknown-type fall-through, header reordering, and a load → export →
reparse round-trip.

Closes #232
This commit is contained in:
Joseph Doherty
2026-04-25 18:33:55 -04:00
parent 7ee0cbc3f4
commit 08d8a104bb
6 changed files with 689 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CsvTagImporterTests
{
private const string DeviceHost = "ab://10.10.10.1/0,1";
[Fact]
public void Imports_Kepware_format_controller_tag_with_RW_access()
{
const string csv = """
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
""";
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
var result = importer.Import(csv);
result.Tags.Count.ShouldBe(1);
var t = result.Tags[0];
t.Name.ShouldBe("Motor1_Speed");
t.TagPath.ShouldBe("Motor1_Speed");
t.DataType.ShouldBe(AbCipDataType.DInt);
t.Writable.ShouldBeTrue();
t.Description.ShouldBe("Drive speed setpoint");
t.DeviceHostAddress.ShouldBe(DeviceHost);
}
[Fact]
public void Read_Only_access_yields_non_writable_tag()
{
const string csv = """
Tag Name,Address,Data Type,Client Access,Description
Sensor,Sensor,REAL,Read Only,Pressure sensor
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Writable.ShouldBeFalse();
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void Blank_rows_and_section_markers_are_skipped()
{
const string csv = """
; Kepware Server Tag Export
Tag Name,Address,Data Type,Client Access
; group: Motors
Motor1,Motor1,DINT,Read/Write
Motor2,Motor2,DINT,Read/Write
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Count.ShouldBe(2);
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
result.SkippedBlankCount.ShouldBeGreaterThan(0);
}
[Fact]
public void Quoted_field_with_embedded_comma_is_parsed()
{
const string csv = """
Tag Name,Address,Data Type,Client Access,Description
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Description.ShouldBe("Speed, RPM");
}
[Fact]
public void Quoted_field_with_escaped_quote_is_parsed()
{
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
}
[Fact]
public void NamePrefix_is_applied()
{
const string csv = """
Tag Name,Address,Data Type,Client Access
Speed,Speed,DINT,Read/Write
""";
var result = new CsvTagImporter
{
DefaultDeviceHostAddress = DeviceHost,
NamePrefix = "PLC1_",
}.Import(csv);
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
result.Tags.Single().TagPath.ShouldBe("Speed");
}
[Fact]
public void Unknown_data_type_falls_through_as_Structure()
{
const string csv = """
Tag Name,Address,Data Type,Client Access
Mystery,Mystery,SomeUnknownType,Read/Write
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Throws_when_DefaultDeviceHostAddress_missing()
{
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
}
[Fact]
public void Round_trip_load_export_reparse_is_stable()
{
var original = new[]
{
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
Writable: true, Description: "Drive speed"),
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
Writable: false, Description: "Pressure, kPa"),
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
Writable: true, Description: null),
};
var csv = CsvTagExporter.ToCsv(original);
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
reparsed.Count.ShouldBe(original.Length);
for (var i = 0; i < original.Length; i++)
{
reparsed[i].Name.ShouldBe(original[i].Name);
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
reparsed[i].DataType.ShouldBe(original[i].DataType);
reparsed[i].Writable.ShouldBe(original[i].Writable);
reparsed[i].Description.ShouldBe(original[i].Description);
}
}
[Fact]
public void Reordered_columns_are_honoured_via_header_lookup()
{
const string csv = """
Description,Address,Tag Name,Client Access,Data Type
Drive speed,Motor1,Motor1,Read/Write,DINT
""";
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
var t = result.Tags.Single();
t.Name.ShouldBe("Motor1");
t.TagPath.ShouldBe("Motor1");
t.DataType.ShouldBe(AbCipDataType.DInt);
t.Description.ShouldBe("Drive speed");
t.Writable.ShouldBeTrue();
}
}