fix(adminui): equipment CSV import reads RFC 4180 via CsvParser (R04 S6)
ImportEquipmentModal split each line on commas, so a quoted Manufacturer with a comma silently shifted every later column. EquipmentCsvParser reads through the shared Commons CsvParser (the raw-tag importer's authority): quoted fields, doubled quotes, CRLF and blank lines parse; malformed quoting is an error, not a guess. Header/shape messages are unchanged. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+4
-54
@@ -75,8 +75,6 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
@@ -134,60 +132,12 @@
|
||||
private Task CloseAsync() =>
|
||||
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
|
||||
/// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
|
||||
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
|
||||
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
|
||||
/// </summary>
|
||||
/// <summary>Parses the pasted CSV via <see cref="EquipmentCsvParser"/> (RFC 4180, R04 S6); sets
|
||||
/// <see cref="_parseError"/> and returns null on an empty/malformed CSV or a wrong header.</summary>
|
||||
private List<EquipmentInput>? ParseCsv(string csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
|
||||
|
||||
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
|
||||
|
||||
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
|
||||
for (var i = 0; i < RequiredColumns.Length; i++)
|
||||
{
|
||||
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<EquipmentInput>();
|
||||
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
|
||||
{
|
||||
// NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.
|
||||
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
|
||||
if (parts.Length < RequiredColumns.Length)
|
||||
{
|
||||
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
|
||||
return null;
|
||||
}
|
||||
|
||||
rows.Add(new EquipmentInput(
|
||||
Name: parts[0],
|
||||
MachineCode: parts[1],
|
||||
UnsLineId: parts[2],
|
||||
ZTag: NullIfEmpty(parts, 3),
|
||||
SAPID: NullIfEmpty(parts, 4),
|
||||
Manufacturer: NullIfEmpty(parts, 5),
|
||||
Model: NullIfEmpty(parts, 6),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
YearOfConstruction: null,
|
||||
AssetLocation: null,
|
||||
ManufacturerUri: null,
|
||||
DeviceManualUri: null,
|
||||
Enabled: true));
|
||||
}
|
||||
var (rows, error) = EquipmentCsvParser.Parse(csv);
|
||||
_parseError = error;
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string[] parts, int idx) =>
|
||||
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the equipment-import CSV (R04 S6). It used to be a naive comma split, so a quoted
|
||||
/// <c>Manufacturer</c> containing a comma silently shifted every later column with no error. It now
|
||||
/// reads through the RFC 4180 <see cref="CsvParser"/> — the same authority the raw-tag importer uses —
|
||||
/// so quoted fields, embedded commas/newlines and doubled quotes parse correctly, and malformed quoting
|
||||
/// is reported instead of guessed at.
|
||||
/// </summary>
|
||||
public static class EquipmentCsvParser
|
||||
{
|
||||
/// <summary>The leading header columns the import requires, in order (case-insensitive).</summary>
|
||||
public static readonly IReadOnlyList<string> RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
|
||||
|
||||
/// <summary>
|
||||
/// Parses the CSV. Requires a header row whose first three columns are Name, MachineCode, UnsLineId;
|
||||
/// the optional ZTag, SAPID, Manufacturer, Model follow. Cells are trimmed; blank optional cells parse
|
||||
/// as null; blank lines are skipped.
|
||||
/// </summary>
|
||||
/// <param name="csv">The pasted CSV text.</param>
|
||||
/// <returns>The rows, or a null list with an operator-facing error.</returns>
|
||||
public static (List<EquipmentInput>? Rows, string? Error) Parse(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) return (null, "CSV is empty.");
|
||||
|
||||
IReadOnlyList<string[]> records;
|
||||
try
|
||||
{
|
||||
records = CsvParser.Parse(csv);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return (null, $"The CSV is malformed: {ex.Message}");
|
||||
}
|
||||
|
||||
var lines = records
|
||||
.Where(r => r.Length > 1 || r[0].Trim().Length > 0)
|
||||
.Select(r => r.Select(c => c.Trim()).ToArray())
|
||||
.ToList();
|
||||
if (lines.Count < 2) return (null, "Need a header row and at least one data row.");
|
||||
|
||||
var header = lines[0];
|
||||
for (var i = 0; i < RequiredColumns.Count; i++)
|
||||
{
|
||||
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').");
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<EquipmentInput>(lines.Count - 1);
|
||||
for (var lineIdx = 1; lineIdx < lines.Count; lineIdx++)
|
||||
{
|
||||
var parts = lines[lineIdx];
|
||||
if (parts.Length < RequiredColumns.Count)
|
||||
{
|
||||
return (null, $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Count}).");
|
||||
}
|
||||
|
||||
rows.Add(new EquipmentInput(
|
||||
Name: parts[0],
|
||||
MachineCode: parts[1],
|
||||
UnsLineId: parts[2],
|
||||
ZTag: NullIfEmpty(parts, 3),
|
||||
SAPID: NullIfEmpty(parts, 4),
|
||||
Manufacturer: NullIfEmpty(parts, 5),
|
||||
Model: NullIfEmpty(parts, 6),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
YearOfConstruction: null,
|
||||
AssetLocation: null,
|
||||
ManufacturerUri: null,
|
||||
DeviceManualUri: null,
|
||||
Enabled: true));
|
||||
}
|
||||
|
||||
return (rows, null);
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string[] parts, int idx) =>
|
||||
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>R04 S6: the equipment CSV import reads RFC 4180 through the shared CsvParser.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentCsvParserTests
|
||||
{
|
||||
private const string Header = "Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model";
|
||||
|
||||
[Fact]
|
||||
public void A_quoted_comma_stays_in_its_column()
|
||||
{
|
||||
var (rows, error) = EquipmentCsvParser.Parse(
|
||||
Header + "\nPress 1,MC-1,LINE-1,Z1,S1,\"Acme, Inc.\",X200\n");
|
||||
|
||||
error.ShouldBeNull();
|
||||
rows!.Single().Manufacturer.ShouldBe("Acme, Inc.");
|
||||
// The column after the quoted comma did not shift.
|
||||
rows!.Single().Model.ShouldBe("X200");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Doubled_quotes_crlf_and_blank_lines_parse()
|
||||
{
|
||||
var (rows, error) = EquipmentCsvParser.Parse(
|
||||
Header + "\r\n\r\n\"Press \"\"A\"\"\",MC-1,LINE-1,,,,\r\nPress B,MC-2,LINE-1\r\n");
|
||||
|
||||
error.ShouldBeNull();
|
||||
rows!.Select(r => r.Name).ShouldBe(["Press \"A\"", "Press B"]);
|
||||
rows![0].ZTag.ShouldBeNull();
|
||||
rows[1].Manufacturer.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_and_shape_errors_are_reported()
|
||||
{
|
||||
EquipmentCsvParser.Parse("").Error.ShouldBe("CSV is empty.");
|
||||
EquipmentCsvParser.Parse(Header).Error.ShouldBe("Need a header row and at least one data row.");
|
||||
EquipmentCsvParser.Parse("Name,Code,UnsLineId\na,b,c").Error!.ShouldContain("Header column #2 must be 'MachineCode'");
|
||||
EquipmentCsvParser.Parse(Header + "\nonly,two").Error!.ShouldContain("Row 1: too few columns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_quoting_is_an_error_not_a_guess()
|
||||
{
|
||||
var (rows, error) = EquipmentCsvParser.Parse(Header + "\nPress,MC-1,LINE-1,\"unterminated\n");
|
||||
|
||||
rows.ShouldBeNull();
|
||||
error!.ShouldStartWith("The CSV is malformed:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modal_delegates_to_the_parser()
|
||||
{
|
||||
var src = AdminUiSource.Read("Components", "Shared", "Uns", "ImportEquipmentModal.razor");
|
||||
src.ShouldContain("EquipmentCsvParser.Parse(csv)");
|
||||
src.ShouldNotContain(".Split(',')");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user