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,124 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
/// <summary>
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
/// re-import via the driver's <c>CsvImports</c>.
/// </summary>
/// <remarks>
/// The command does not contact any PLC — it is a pure transform over the options JSON.
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
/// deserialises to <see cref="AbCipDriverOptions"/>.
/// </remarks>
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
public sealed class TagExportCommand : ICommand
{
[CommandOption("driver-options-json", Description =
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
IsRequired = true)]
public string DriverOptionsJsonPath { get; init; } = default!;
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
public string OutputPath { get; init; } = default!;
public ValueTask ExecuteAsync(IConsole console)
{
if (!File.Exists(DriverOptionsJsonPath))
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
var json = File.ReadAllText(DriverOptionsJsonPath);
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
?? throw new CommandException("driver-options-json deserialised to null.");
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
var declaredNames = new HashSet<string>(
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
var allTags = new List<AbCipTagDefinition>(opts.Tags);
foreach (var import in opts.L5kImports)
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
foreach (var import in opts.L5xImports)
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
foreach (var import in opts.CsvImports)
MergeCsv(import, basePath, declaredNames, allTags);
CsvTagExporter.WriteFile(allTags, OutputPath);
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
return ValueTask.CompletedTask;
}
private static string? ResolvePath(string? path, string basePath)
{
if (string.IsNullOrEmpty(path)) return path;
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
}
private static void MergeL5(
string deviceHost, string? filePath, string? inlineText, string namePrefix,
Func<IL5kSource, L5kDocument> parse,
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(deviceHost)) return;
IL5kSource? src = null;
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
if (src is null) return;
var doc = parse(src);
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
foreach (var tag in ingest.Ingest(doc).Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
private static void MergeCsv(
AbCipCsvImportOptions import, string basePath,
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
string? text = null;
var resolved = ResolvePath(import.FilePath, basePath);
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
if (text is null) return;
var importer = new CsvTagImporter
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
foreach (var tag in importer.Import(text).Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
}

View File

@@ -152,6 +152,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
declaredNames: declaredNames,
allTags: allTags);
}
foreach (var import in _options.CsvImports)
{
MergeCsvImport(import, declaredNames, allTags);
}
foreach (var tag in allTags)
{
@@ -234,6 +238,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// CSV-import variant of <see cref="MergeImport"/>. The CSV path produces
/// <see cref="AbCipTagDefinition"/> records directly (no intermediate document) so we
/// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical:
/// a name already covered by a declaration or an earlier import is left untouched so
/// the precedence chain (declared > L5K > L5X > CSV) holds.
/// </summary>
private static void MergeCsvImport(
AbCipCsvImportOptions import,
HashSet<string> declaredNames,
List<AbCipTagDefinition> allTags)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
throw new InvalidOperationException(
"AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device.");
string? csvText = null;
if (!string.IsNullOrEmpty(import.FilePath))
csvText = System.IO.File.ReadAllText(import.FilePath);
else if (!string.IsNullOrEmpty(import.InlineText))
csvText = import.InlineText;
if (csvText is null) return;
var importer = new CsvTagImporter
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
var result = importer.Import(csvText);
foreach (var tag in result.Tags)
{
if (declaredNames.Contains(tag.Name)) continue;
allTags.Add(tag);
declaredNames.Add(tag.Name);
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -42,6 +42,16 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
/// <summary>
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
/// so an Excel-edited copy of the same controller does not double-emit. See
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
/// </summary>
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -187,6 +197,20 @@ public sealed record AbCipL5xImportOptions(
string? InlineText = null,
string NamePrefix = "");
/// <summary>
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
/// so configuration JSON stays consistent across the three import sources.
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
public sealed record AbCipCsvImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
{

View File

@@ -0,0 +1,99 @@
using System.Globalization;
using System.IO;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
/// and exporter form a complete round-trip path: load → export → reparse → identical
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
/// </summary>
public static class CsvTagExporter
{
public static readonly IReadOnlyList<string> KepwareColumns =
[
"Tag Name",
"Address",
"Data Type",
"Respect Data Type",
"Client Access",
"Scan Rate",
"Description",
"Scaling",
];
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
{
ArgumentNullException.ThrowIfNull(tags);
ArgumentNullException.ThrowIfNull(writer);
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
foreach (var tag in tags)
{
var fields = new[]
{
tag.Name ?? string.Empty,
tag.TagPath ?? string.Empty,
FormatDataType(tag.DataType),
"1", // Respect Data Type — Kepware EX default.
tag.Writable ? "Read/Write" : "Read Only",
"100", // Scan Rate (ms) — placeholder default.
tag.Description ?? string.Empty,
"None", // Scaling — driver doesn't apply scaling.
};
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
}
}
/// <summary>Render the tag list to a string.</summary>
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
{
using var sw = new StringWriter(CultureInfo.InvariantCulture);
Write(tags, sw);
return sw.ToString();
}
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
{
ArgumentNullException.ThrowIfNull(path);
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
Write(tags, sw);
}
private static string FormatDataType(AbCipDataType t) => t switch
{
AbCipDataType.Bool => "BOOL",
AbCipDataType.SInt => "SINT",
AbCipDataType.Int => "INT",
AbCipDataType.DInt => "DINT",
AbCipDataType.LInt => "LINT",
AbCipDataType.USInt => "USINT",
AbCipDataType.UInt => "UINT",
AbCipDataType.UDInt => "UDINT",
AbCipDataType.ULInt => "ULINT",
AbCipDataType.Real => "REAL",
AbCipDataType.LReal => "LREAL",
AbCipDataType.String => "STRING",
AbCipDataType.Dt => "DT",
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
_ => "STRING",
};
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
private static string EscapeField(string value)
{
value ??= string.Empty;
var needsQuotes =
value.IndexOf(',') >= 0 ||
value.IndexOf('"') >= 0 ||
value.IndexOf('\r') >= 0 ||
value.IndexOf('\n') >= 0;
if (!needsQuotes) return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,226 @@
using System.Globalization;
using System.IO;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
/// The expected column layout matches the Kepware EX tag-export shape so operators can
/// round-trip tags through Excel without re-keying:
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
/// header — column order is honoured by name lookup, so reorderings out of Excel still
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
/// (<c>;</c> / <c>#</c>) are skipped.
/// </summary>
/// <remarks>
/// <para>
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
/// unknown types);
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
/// is Read-Only.
/// </para>
/// <para>
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
/// narrow — Kepware's exporter does not produce anything more exotic.
/// </para>
/// </remarks>
public sealed class CsvTagImporter
{
/// <summary>Default device host address applied to every imported tag.</summary>
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
public string NamePrefix { get; init; } = string.Empty;
public CsvTagImportResult Import(string csvText)
{
ArgumentNullException.ThrowIfNull(csvText);
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
throw new InvalidOperationException(
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
var rows = CsvReader.ReadAll(csvText);
var tags = new List<AbCipTagDefinition>();
var skippedBlank = 0;
Dictionary<string, int>? header = null;
foreach (var row in rows)
{
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
{
skippedBlank++;
continue;
}
var first = row[0].TrimStart();
if (first.StartsWith(';') || first.StartsWith('#'))
{
skippedBlank++;
continue;
}
if (header is null)
{
header = BuildHeader(row);
continue;
}
var name = GetCell(row, header, "Tag Name");
if (string.IsNullOrWhiteSpace(name))
{
skippedBlank++;
continue;
}
var address = GetCell(row, header, "Address");
var dataTypeText = GetCell(row, header, "Data Type");
var description = GetCell(row, header, "Description");
var clientAccess = GetCell(row, header, "Client Access");
var dataType = ParseDataType(dataTypeText);
var writable = !string.IsNullOrEmpty(clientAccess)
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
tags.Add(new AbCipTagDefinition(
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
DeviceHostAddress: DefaultDeviceHostAddress,
TagPath: string.IsNullOrEmpty(address) ? name : address,
DataType: dataType,
Writable: writable,
Description: string.IsNullOrEmpty(description) ? null : description));
}
return new CsvTagImportResult(tags, skippedBlank);
}
public CsvTagImportResult ImportFile(string path) =>
Import(File.ReadAllText(path, Encoding.UTF8));
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
{
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < row.Count; i++)
{
var key = row[i]?.Trim() ?? string.Empty;
if (key.Length > 0 && !dict.ContainsKey(key))
dict[key] = i;
}
return dict;
}
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
{
if (!header.TryGetValue(column, out var idx)) return string.Empty;
if (idx < 0 || idx >= row.Count) return string.Empty;
return row[idx]?.Trim() ?? string.Empty;
}
private static AbCipDataType ParseDataType(string s) =>
s?.Trim().ToUpperInvariant() switch
{
"BOOL" or "BIT" => AbCipDataType.Bool,
"SINT" or "BYTE" => AbCipDataType.SInt,
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
"LINT" => AbCipDataType.LInt,
"USINT" => AbCipDataType.USInt,
"UINT" => AbCipDataType.UInt,
"UDINT" => AbCipDataType.UDInt,
"ULINT" => AbCipDataType.ULInt,
"REAL" or "FLOAT" => AbCipDataType.Real,
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
"STRING" => AbCipDataType.String,
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
_ => AbCipDataType.Structure,
};
}
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
public sealed record CsvTagImportResult(
IReadOnlyList<AbCipTagDefinition> Tags,
int SkippedBlankCount);
/// <summary>
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
/// exporter are the only two callers and we don't want to add a CSV dep.
/// </summary>
internal static class CsvReader
{
public static List<List<string>> ReadAll(string text)
{
var rows = new List<List<string>>();
var row = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < text.Length && text[i + 1] == '"')
{
field.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
field.Append(c);
}
continue;
}
switch (c)
{
case '"':
inQuotes = true;
break;
case ',':
row.Add(field.ToString());
field.Clear();
break;
case '\r':
// Swallow CR — handle CRLF and lone CR alike.
row.Add(field.ToString());
field.Clear();
rows.Add(row);
row = new List<string>();
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
break;
case '\n':
row.Add(field.ToString());
field.Clear();
rows.Add(row);
row = new List<string>();
break;
default:
field.Append(c);
break;
}
}
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row);
}
return rows;
}
}

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