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