diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs new file mode 100644 index 0000000..38ad8b1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/TagExportCommand.cs @@ -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; + +/// +/// Dump the merged tag table from an JSON config to a +/// Kepware-format CSV. The command reads the pre-declared Tags list, pulls in any +/// L5kImports / L5xImports / CsvImports 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 CsvImports. +/// +/// +/// The command does not contact any PLC — it is a pure transform over the options JSON. +/// --driver-options-json may point at a full options file or at a fragment that +/// deserialises to . +/// +[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(json, JsonOpts) + ?? throw new CommandException("driver-options-json deserialised to null."); + + var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty; + + var declaredNames = new HashSet( + opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase); + var allTags = new List(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 parse, + HashSet declaredNames, List 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 declaredNames, List 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() }, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 6287fe0..d89fb51 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -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, } } + /// + /// CSV-import variant of . The CSV path produces + /// 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. + /// + private static void MergeCsvImport( + AbCipCsvImportOptions import, + HashSet declaredNames, + List 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index deedda2..2a6f4c7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -42,6 +42,16 @@ public sealed class AbCipDriverOptions /// public IReadOnlyList L5xImports { get; init; } = []; + /// + /// Kepware-format CSV imports merged into at InitializeAsync. + /// Same merge semantics as / — + /// pre-declared entries win on Name 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 + /// for the column layout + parse rules. + /// + public IReadOnlyList CsvImports { get; init; } = []; + /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); @@ -187,6 +197,20 @@ public sealed record AbCipL5xImportOptions( string? InlineText = null, string NamePrefix = ""); +/// +/// One Kepware-format CSV import entry. Field shape mirrors +/// so configuration JSON stays consistent across the three import sources. +/// +/// Target device HostAddress tags from this file are bound to. +/// On-disk path to a Kepware-format *.csv. Loaded eagerly at InitializeAsync. +/// Pre-loaded CSV body — used by tests + Admin UI uploads. +/// Optional prefix prepended to imported tag names to avoid collisions. +public sealed record AbCipCsvImportOptions( + string DeviceHostAddress, + string? FilePath = null, + string? InlineText = null, + string NamePrefix = ""); + /// Which AB PLC family the device is — selects the profile applied to connection params. public enum AbCipPlcFamily { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs new file mode 100644 index 0000000..f345c7d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using System.IO; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// Render an enumerable of as a Kepware-format CSV +/// document. Emits the header expected by so the importer +/// and exporter form a complete round-trip path: load → export → reparse → identical +/// entries (modulo unknown-type tags, which export as STRING and reimport as +/// per the importer's fall-through rule). +/// +public static class CsvTagExporter +{ + public static readonly IReadOnlyList KepwareColumns = + [ + "Tag Name", + "Address", + "Data Type", + "Respect Data Type", + "Client Access", + "Scan Rate", + "Description", + "Scaling", + ]; + + /// Write the tag list to in Kepware CSV format. + public static void Write(IEnumerable 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))); + } + } + + /// Render the tag list to a string. + public static string ToCsv(IEnumerable tags) + { + using var sw = new StringWriter(CultureInfo.InvariantCulture); + Write(tags, sw); + return sw.ToString(); + } + + /// Write the tag list to as UTF-8 (no BOM). + public static void WriteFile(IEnumerable 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", + }; + + /// Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling. + 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("\"", "\"\"") + "\""; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs new file mode 100644 index 0000000..cee686a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs @@ -0,0 +1,226 @@ +using System.Globalization; +using System.IO; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// Parse a Kepware-format AB CIP tag CSV into entries. +/// The expected column layout matches the Kepware EX tag-export shape so operators can +/// round-trip tags through Excel without re-keying: +/// Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate, +/// Description, Scaling. 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 +/// (; / #) are skipped. +/// +/// +/// +/// Mapping: Tag Name; +/// Address; +/// Data Type (Logix atomic name — +/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as +/// the same way handles +/// unknown types); +/// Description; +/// Client Access: any value +/// containing W (case-insensitive) is treated as Read/Write; everything else +/// is Read-Only. +/// +/// +/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line +/// breaks, and escaped quotes (""). The parser is single-pass + deliberately +/// narrow — Kepware's exporter does not produce anything more exotic. +/// +/// +public sealed class CsvTagImporter +{ + /// Default device host address applied to every imported tag. + public string DefaultDeviceHostAddress { get; init; } = string.Empty; + + /// Optional prefix prepended to each imported tag's name. Default empty. + 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(); + var skippedBlank = 0; + Dictionary? 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 BuildHeader(IReadOnlyList row) + { + var dict = new Dictionary(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 row, Dictionary 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, + }; +} + +/// Result of . +public sealed record CsvTagImportResult( + IReadOnlyList Tags, + int SkippedBlankCount); + +/// +/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped "" +/// 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. +/// +internal static class CsvReader +{ + public static List> ReadAll(string text) + { + var rows = new List>(); + var row = new List(); + 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(); + 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(); + break; + default: + field.Append(c); + break; + } + } + + if (field.Length > 0 || row.Count > 0) + { + row.Add(field.ToString()); + rows.Add(row); + } + + return rows; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs new file mode 100644 index 0000000..10ec26d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CsvTagImporterTests.cs @@ -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(() => 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(); + } +}