Auto: ablegacy-11 — RSLogix 500/PLC-5 CSV symbol import

Closes #254
This commit is contained in:
Joseph Doherty
2026-04-26 04:13:13 -04:00
parent 4fdeef7a6c
commit 4e8df38bb2
19 changed files with 1644 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
using System.IO;
using System.Text.Json;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
/// <summary>
/// Coverage for the <c>import-rslogix</c> CLI command. The command is intentionally
/// thin (open file, hand to <c>RsLogixSymbolImport</c>, serialise) — these tests focus
/// on the I/O + flag-handling shape rather than re-running the parser.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ImportRslogixCommandTests
{
private const string CanonicalCsv = """
Symbol,Address,Description,DataType,Scope
MotorSpeed,N7:0,Motor speed,INT,Global
TankLevel,F8:0,Tank level,REAL,Global
RunFlag,B3:0/0,Run flag,BOOL,Global
""";
[Fact]
public async Task Execute_with_valid_csv_emits_json_fragment_with_three_tags()
{
var path = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = path,
Device = "ab://10.0.0.5/1,0",
Emit = "appsettings-fragment",
};
await cmd.ExecuteAsync(console);
var output = console.ReadOutputString();
output.ShouldContain("\"Tags\"");
// Parse the emitted JSON and assert the structural properties — flake-resistant
// vs. comparing whitespace-sensitive text.
using var doc = JsonDocument.Parse(output);
var tags = doc.RootElement.GetProperty("Tags");
tags.GetArrayLength().ShouldBe(3);
tags[0].GetProperty("Name").GetString().ShouldBe("MotorSpeed");
tags[0].GetProperty("DataType").GetString().ShouldBe("Int");
tags[2].GetProperty("DataType").GetString().ShouldBe("Bit");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_summary_emit_prints_counters()
{
var path = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = path,
Device = "ab://10.0.0.5/1,0",
Emit = "summary",
};
await cmd.ExecuteAsync(console);
var output = console.ReadOutputString();
output.ShouldContain("Imported 3");
output.ShouldContain("skipped 0");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_output_path_writes_file_and_prints_summary()
{
var inputPath = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.csv");
var outputPath = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.json");
File.WriteAllText(inputPath, CanonicalCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = inputPath,
Device = "ab://10.0.0.5/1,0",
Emit = "appsettings-fragment",
Output = outputPath,
};
await cmd.ExecuteAsync(console);
File.Exists(outputPath).ShouldBeTrue();
var fileBody = File.ReadAllText(outputPath);
using var doc = JsonDocument.Parse(fileBody);
doc.RootElement.GetProperty("Tags").GetArrayLength().ShouldBe(3);
// Stdout still gets the human-readable summary line.
console.ReadOutputString().ShouldContain("Wrote 3");
}
finally
{
try { File.Delete(inputPath); } catch { /* best-effort */ }
try { File.Delete(outputPath); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_with_missing_file_throws_command_exception()
{
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.csv");
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = missing,
Device = "ab://10.0.0.5/1,0",
};
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
ex.ExitCode.ShouldBe(1);
}
[Fact]
public async Task Execute_with_unknown_emit_throws_command_exception()
{
var path = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, CanonicalCsv);
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = path,
Device = "ab://10.0.0.5/1,0",
Emit = "yaml",
};
var ex = await Should.ThrowAsync<CommandException>(async () => await cmd.ExecuteAsync(console));
ex.ExitCode.ShouldBe(2);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public async Task Execute_scope_filter_only_imports_matching_rows()
{
var path = Path.Combine(Path.GetTempPath(), $"rslogix-cli-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Symbol,Address,Description,DataType,Scope
G1,N7:0,desc,INT,Global
L1,N7:1,desc,INT,Local:1
L2,N7:2,desc,INT,Local:2
""");
try
{
using var console = new FakeInMemoryConsole();
var cmd = new ImportRslogixCommand
{
File = path,
Device = "ab://10.0.0.5/1,0",
Emit = "summary",
Scope = "Local:1",
};
await cmd.ExecuteAsync(console);
console.ReadOutputString().ShouldContain("Imported 1");
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,60 @@
{
"Tags": [
{
"Name": "MotorSpeed",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "N7:0",
"DataType": "Int",
"Writable": true
},
{
"Name": "TankLevel",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "F8:0",
"DataType": "Float",
"Writable": true
},
{
"Name": "RunFlag",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "B3:0/0",
"DataType": "Bit",
"Writable": true
},
{
"Name": "TotalCount",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "L9:0",
"DataType": "Long",
"Writable": true
},
{
"Name": "RecipeName",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "ST10:0",
"DataType": "String",
"Writable": true
},
{
"Name": "DwellTimer",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "T4:0.ACC",
"DataType": "TimerElement",
"Writable": true
},
{
"Name": "PieceCounter",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "C5:0.ACC",
"DataType": "CounterElement",
"Writable": true
},
{
"Name": "StateMachine",
"DeviceHostAddress": "ab://192.168.1.20/1,0",
"Address": "R6:0.LEN",
"DataType": "ControlElement",
"Writable": true
}
]
}

View File

@@ -0,0 +1,13 @@
; ablegacy-11 / #254 — canonical RSLogix CSV symbol export covering one row per
; file letter the v1 importer recognises (N/F/B/L/ST/T/C/R). Comment lines
; (starting with `;` or `#`) are skipped by the parser so this header doc
; survives a round-trip without affecting the imported tag count.
Symbol,Address,Description,DataType,Scope
MotorSpeed,N7:0,Motor speed setpoint,INT,Global
TankLevel,F8:0,Tank level (gallons),REAL,Global
RunFlag,B3:0/0,Run command flag,BOOL,Global
TotalCount,L9:0,Total piece count,LINT,Global
RecipeName,ST10:0,"Recipe name, free-form text",STRING,Global
DwellTimer,T4:0.ACC,Dwell timer accumulator,TIMER,Global
PieceCounter,C5:0.ACC,Piece counter accumulator,COUNTER,Global
StateMachine,R6:0.LEN,State-machine control length,CONTROL,Global
1 ; ablegacy-11 / #254 — canonical RSLogix CSV symbol export covering one row per
2 ; file letter the v1 importer recognises (N/F/B/L/ST/T/C/R). Comment lines
3 ; (starting with `;` or `#`) are skipped by the parser so this header doc
4 ; survives a round-trip without affecting the imported tag count.
5 Symbol Address Description DataType Scope
6 MotorSpeed N7:0 Motor speed setpoint INT Global
7 TankLevel F8:0 Tank level (gallons) REAL Global
8 RunFlag B3:0/0 Run command flag BOOL Global
9 TotalCount L9:0 Total piece count LINT Global
10 RecipeName ST10:0 Recipe name, free-form text STRING Global
11 DwellTimer T4:0.ACC Dwell timer accumulator TIMER Global
12 PieceCounter C5:0.ACC Piece counter accumulator COUNTER Global
13 StateMachine R6:0.LEN State-machine control length CONTROL Global

View File

@@ -0,0 +1,82 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.Import;
/// <summary>
/// Coverage for <see cref="AbLegacyDriverFactoryExtensions.AddRsLogixImport"/> — the
/// extension method that opens a CSV file and concatenates the resulting tag definitions
/// onto an existing <see cref="AbLegacyDriverOptions"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDriverFactoryAddRsLogixImportTests
{
[Fact]
public void AddRsLogixImport_appends_tags_and_preserves_existing_options()
{
// Existing options have one device + one hand-rolled tag. The importer should
// append on top — never replace — so the device + the original tag survive.
var path = Path.Combine(Path.GetTempPath(), $"rslogix-import-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Symbol,Address,Description,DataType,Scope
New1,N7:0,desc,INT,Global
New2,F8:0,desc,REAL,Global
""");
try
{
var existingTag = new AbLegacyTagDefinition(
Name: "Manual",
DeviceHostAddress: "ab://10.0.0.1/1,0",
Address: "S:0",
DataType: AbLegacyDataType.Int);
var options = new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.1/1,0", AbLegacyPlcFamily.Slc500)],
Tags = [existingTag],
};
var updated = options.AddRsLogixImport(path, "ab://10.0.0.1/1,0", out var result);
// Imported counts surface on the result.
result.ParsedCount.ShouldBe(2);
// Devices + the original Manual tag are preserved on the returned options.
updated.Devices.Count.ShouldBe(1);
updated.Tags.Count.ShouldBe(3);
updated.Tags[0].Name.ShouldBe("Manual");
updated.Tags[1].Name.ShouldBe("New1");
updated.Tags[2].Name.ShouldBe("New2");
// Original options object is unchanged (immutability guarantee).
options.Tags.Count.ShouldBe(1);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
[Fact]
public void AddRsLogixImportWithResult_returns_tuple()
{
var path = Path.Combine(Path.GetTempPath(), $"rslogix-import-{Guid.NewGuid():N}.csv");
File.WriteAllText(path, """
Symbol,Address,Description,DataType,Scope
T,N7:0,desc,INT,Global
""");
try
{
var options = new AbLegacyDriverOptions();
var (updated, result) = options.AddRsLogixImportWithResult(path, "ab://10.0.0.1/1,0");
result.ParsedCount.ShouldBe(1);
updated.Tags.Count.ShouldBe(1);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
}

View File

@@ -0,0 +1,107 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.Import;
/// <summary>
/// End-to-end golden-snapshot test. Loads the canonical CSV fixture from
/// <c>Fixtures/rslogix-canonical.csv</c>, runs it through
/// <see cref="RsLogixSymbolImport"/>, then compares the resulting tag list to
/// <c>Fixtures/rslogix-canonical-expected.json</c>.
/// </summary>
/// <remarks>
/// <para>
/// Mismatch path: the test writes the actual JSON to a temp file path and prints the
/// path in the failure message, so the dev can run
/// <c>cp $TEMP/rslogix-canonical-actual.json tests/.../Fixtures/rslogix-canonical-expected.json</c>
/// to bless the new shape. Treats both sides as <see cref="JsonNode"/> trees so
/// insignificant whitespace + key-order differences don't false-fail.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class RsLogixSymbolImportGoldenTests
{
private const string Device = "ab://192.168.1.20/1,0";
private static string FixturePath(string name) =>
Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
[Fact]
public void Canonical_csv_matches_golden_json()
{
var importer = new RsLogixSymbolImport(NullLogger<RsLogixSymbolImport>.Instance);
using var stream = File.OpenRead(FixturePath("rslogix-canonical.csv"));
var result = importer.Parse(stream, Device);
result.ParsedCount.ShouldBe(8);
result.SkippedCount.ShouldBe(0);
result.ErrorCount.ShouldBe(0);
var actualPayload = new
{
Tags = result.Tags.Select(t => new
{
Name = t.Name,
DeviceHostAddress = t.DeviceHostAddress,
Address = t.Address,
DataType = t.DataType.ToString(),
Writable = t.Writable,
}).ToArray()
};
var actualJson = JsonSerializer.Serialize(actualPayload,
new JsonSerializerOptions { WriteIndented = true });
var expectedJson = File.ReadAllText(FixturePath("rslogix-canonical-expected.json"));
var actualNode = JsonNode.Parse(actualJson)!;
var expectedNode = JsonNode.Parse(expectedJson)!;
if (!JsonTreesEqual(actualNode, expectedNode))
{
// Dump the actual JSON to a discoverable temp path so the dev can `cp` it over
// the fixture once they've reviewed the diff. The test message points straight
// at the file.
var dump = Path.Combine(Path.GetTempPath(), "rslogix-canonical-actual.json");
File.WriteAllText(dump, actualJson);
throw new Xunit.Sdk.XunitException(
$"RSLogix golden mismatch. Actual written to: {dump}\n--- Expected ---\n{expectedJson}\n--- Actual ---\n{actualJson}");
}
}
/// <summary>
/// Structural JSON equality — recursively compares two <see cref="JsonNode"/> trees
/// by shape + value, ignoring property-order on objects. Cheaper than pulling in a
/// dedicated diff library for one assertion.
/// </summary>
private static bool JsonTreesEqual(JsonNode? a, JsonNode? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
if (a is JsonObject ao && b is JsonObject bo)
{
if (ao.Count != bo.Count) return false;
foreach (var kvp in ao)
{
if (!bo.TryGetPropertyValue(kvp.Key, out var bv)) return false;
if (!JsonTreesEqual(kvp.Value, bv)) return false;
}
return true;
}
if (a is JsonArray aa && b is JsonArray ba)
{
if (aa.Count != ba.Count) return false;
for (var i = 0; i < aa.Count; i++)
{
if (!JsonTreesEqual(aa[i], ba[i])) return false;
}
return true;
}
// Primitives — fall back to canonical JSON form for value equality.
return a.ToJsonString() == b.ToJsonString();
}
}

View File

@@ -0,0 +1,266 @@
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.Import;
/// <summary>
/// Unit coverage for <see cref="RsLogixSymbolImport"/>. Drives the parser through
/// synthesised in-memory streams — the golden-snapshot fixture has its own dedicated
/// test class (<see cref="RsLogixSymbolImportGoldenTests"/>).
/// </summary>
[Trait("Category", "Unit")]
public sealed class RsLogixSymbolImportTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static RsLogixImportResult ParseString(string csv, ImportOptions? opts = null)
{
var importer = new RsLogixSymbolImport(NullLogger<RsLogixSymbolImport>.Instance);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
return importer.Parse(stream, Device, opts);
}
[Fact]
public void Parse_canonical_eight_file_letters_yields_eight_typed_tags()
{
// One row per file letter the v1 contract supports — N/F/B/L/ST/T/C/R. The expected
// DataType is the file-letter resolution from RsLogixSymbolImport.TryResolveDataType,
// not whatever the RSLogix-supplied DataType column says.
const string csv = """
Symbol,Address,Description,DataType,Scope
S_N,N7:0,n,INT,Global
S_F,F8:0,f,REAL,Global
S_B,B3:0/0,b,BOOL,Global
S_L,L9:0,l,LINT,Global
S_ST,ST10:0,s,STRING,Global
S_T,T4:0.ACC,t,TIMER,Global
S_C,C5:0.ACC,c,COUNTER,Global
S_R,R6:0.LEN,r,CONTROL,Global
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(8);
result.SkippedCount.ShouldBe(0);
result.ErrorCount.ShouldBe(0);
result.Tags.Count.ShouldBe(8);
result.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int);
result.Tags[1].DataType.ShouldBe(AbLegacyDataType.Float);
result.Tags[2].DataType.ShouldBe(AbLegacyDataType.Bit);
result.Tags[3].DataType.ShouldBe(AbLegacyDataType.Long);
result.Tags[4].DataType.ShouldBe(AbLegacyDataType.String);
result.Tags[5].DataType.ShouldBe(AbLegacyDataType.TimerElement);
result.Tags[6].DataType.ShouldBe(AbLegacyDataType.CounterElement);
result.Tags[7].DataType.ShouldBe(AbLegacyDataType.ControlElement);
// Every tag should bind to the supplied device gateway and use the symbol verbatim
// for its Name (no synthesised key — RSLogix symbols are already stable).
result.Tags.ShouldAllBe(t => t.DeviceHostAddress == Device);
result.Tags[0].Name.ShouldBe("S_N");
result.Tags[2].Address.ShouldBe("B3:0/0");
}
[Fact]
public void Parse_skips_header_and_comment_lines()
{
// Comment lines (`;` / `#`) live both before and after the header — both forms must
// survive the parser without bumping the tag count.
const string csv = """
; top-level comment
# also a comment
Symbol,Address,Description,DataType,Scope
; mid-stream comment
Tag1,N7:0,desc,INT,Global
# another comment
Tag2,F8:0,desc,REAL,Global
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(2);
result.Tags[0].Name.ShouldBe("Tag1");
result.Tags[1].Name.ShouldBe("Tag2");
}
[Fact]
public void Parse_malformed_row_skips_with_log_warning()
{
// Malformed rows: missing address. Default IgnoreInvalid=true skips them with a
// warning logged — the surviving row still imports cleanly.
var collector = new ListLogger<RsLogixSymbolImport>();
const string csv = """
Symbol,Address,Description,DataType,Scope
Good,N7:0,ok,INT,Global
Broken,,still here,INT,Global
AlsoGood,F8:0,ok,REAL,Global
""";
var importer = new RsLogixSymbolImport(collector);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(csv));
var result = importer.Parse(stream, Device);
result.ParsedCount.ShouldBe(2);
result.SkippedCount.ShouldBe(1);
// The warning channel saw exactly one entry for the broken row.
collector.Messages.ShouldContain(m => m.Contains("Broken") || m.Contains("missing"));
}
[Fact]
public void Parse_empty_stream_returns_empty_result()
{
var result = ParseString(string.Empty);
result.ParsedCount.ShouldBe(0);
result.SkippedCount.ShouldBe(0);
result.ErrorCount.ShouldBe(0);
result.Tags.ShouldBeEmpty();
}
[Fact]
public void Parse_handles_quoted_field_with_embedded_comma()
{
// Description with an embedded `,` must round-trip through the RFC-4180 splitter
// without splitting the row into extra fields. Address column resolution still
// lands on the correct file letter.
const string csv = """
Symbol,Address,Description,DataType,Scope
Mixer,N7:5,"Mixer speed, RPM",INT,Global
""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("Mixer");
result.Tags[0].Address.ShouldBe("N7:5");
result.Tags[0].DataType.ShouldBe(AbLegacyDataType.Int);
}
[Fact]
public void Parse_doubled_quote_inside_quoted_field_decodes_to_single_quote()
{
// RFC 4180 doubled-quote escape — `""` inside a quoted field is a literal `"`.
// Four-quote raw delimiter so the embedded triple-quote sequence in the CSV
// payload doesn't terminate the literal early.
const string csv = """"
Symbol,Address,Description,DataType,Scope
Quoted,N7:0,"He said ""hi""",INT,Global
"""";
var result = ParseString(csv);
result.ParsedCount.ShouldBe(1);
// The description goes to /dev/null today (AbLegacyTagDefinition has no Description
// field) but the parser still has to consume the row without splitting on the inner
// quotes — a parse-side regression would emit ParsedCount=0 / ErrorCount>=1.
result.ErrorCount.ShouldBe(0);
}
[Fact]
public void Parse_scope_filter_drops_non_matching_rows()
{
const string csv = """
Symbol,Address,Description,DataType,Scope
G,N7:0,global,INT,Global
L1,N7:1,local one,INT,Local:1
L2,N7:2,local two,INT,Local:2
""";
var result = ParseString(csv, new ImportOptions(ScopeFilter: "Local:1"));
result.ParsedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("L1");
result.SkippedCount.ShouldBe(2);
}
[Fact]
public void Parse_handles_utf8_bom()
{
// RSLogix tools on Windows emit UTF-8 with BOM — make sure detectEncodingFromByte-
// OrderMarks=true on the StreamReader strips the BOM rather than letting it become
// part of the first column header (which would knock out the Symbol mapping).
const string csv = "Symbol,Address,Description,DataType,Scope\nT,N7:0,desc,INT,Global\n";
var bom = Encoding.UTF8.GetPreamble();
var bytes = Encoding.UTF8.GetBytes(csv);
var withBom = new byte[bom.Length + bytes.Length];
bom.CopyTo(withBom, 0);
bytes.CopyTo(withBom, bom.Length);
var importer = new RsLogixSymbolImport(NullLogger<RsLogixSymbolImport>.Instance);
using var stream = new MemoryStream(withBom);
var result = importer.Parse(stream, Device);
result.ParsedCount.ShouldBe(1);
result.Tags[0].Name.ShouldBe("T");
}
[Fact]
public void Parse_strict_mode_throws_on_first_invalid_address()
{
const string csv = """
Symbol,Address,Description,DataType,Scope
Good,N7:0,ok,INT,Global
Broken,not-a-pccc-address,bad,INT,Global
""";
// IgnoreInvalid=false → the unrecognised PCCC address surfaces as InvalidDataException
// rather than the silent-skip path.
Should.Throw<InvalidDataException>(
() => ParseString(csv, new ImportOptions(IgnoreInvalid: false)));
}
[Fact]
public void Parse_max_rows_caps_imports()
{
const string csv = """
Symbol,Address,Description,DataType,Scope
A,N7:0,a,INT,Global
B,N7:1,b,INT,Global
C,N7:2,c,INT,Global
D,N7:3,d,INT,Global
""";
var result = ParseString(csv, new ImportOptions(MaxRowsToImport: 2));
result.ParsedCount.ShouldBe(2);
result.Tags.Count.ShouldBe(2);
}
[Fact]
public void Parse_missing_required_column_throws_invalid_data()
{
// No Address column at all — structural failure, not per-row. Throws regardless of
// the IgnoreInvalid knob (the latter governs per-row failures, not header shape).
const string csv = """
Symbol,Description,DataType,Scope
T,desc,INT,Global
""";
Should.Throw<InvalidDataException>(() => ParseString(csv));
}
[Fact]
public void TryResolveDataType_returns_false_for_garbage()
{
RsLogixSymbolImport.TryResolveDataType("not a pccc address", out _).ShouldBeFalse();
RsLogixSymbolImport.TryResolveDataType("", out _).ShouldBeFalse();
}
[Fact]
public void TryResolveDataType_bit_index_overrides_file_letter()
{
// N7:0/3 — bit 3 of word 0 of integer file 7. The bit suffix forces Bit regardless
// of N's normal Int classification.
RsLogixSymbolImport.TryResolveDataType("N7:0/3", out var dt).ShouldBeTrue();
dt.ShouldBe(AbLegacyDataType.Bit);
}
/// <summary>
/// Minimal in-memory <see cref="ILogger{TCategoryName}"/> implementation so the unit
/// tests can assert on the warning side-channel without depending on a logging
/// framework. Captures the formatted message verbatim.
/// </summary>
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}

View File

@@ -23,6 +23,16 @@
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- ablegacy-11 / #254 — RSLogix CSV import fixture + golden snapshot. -->
<None Update="Fixtures\rslogix-canonical.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Fixtures\rslogix-canonical-expected.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>