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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user