diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor index 3a4420b1..86da0727 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor @@ -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"]; /// Whether the modal is shown. The host owns this flag. [Parameter] public bool Visible { get; set; } @@ -134,60 +132,12 @@ private Task CloseAsync() => _result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync(); - /// - /// Parses the pasted CSV into 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 null. Returns - /// null (and sets ) when the CSV is empty or the header is wrong. - /// + /// Parses the pasted CSV via (RFC 4180, R04 S6); sets + /// and returns null on an empty/malformed CSV or a wrong header. private List? 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(); - 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; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs new file mode 100644 index 00000000..f3b44e68 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs @@ -0,0 +1,85 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Csv; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Parses the equipment-import CSV (R04 S6). It used to be a naive comma split, so a quoted +/// Manufacturer containing a comma silently shifted every later column with no error. It now +/// reads through the RFC 4180 — 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. +/// +public static class EquipmentCsvParser +{ + /// The leading header columns the import requires, in order (case-insensitive). + public static readonly IReadOnlyList RequiredColumns = ["Name", "MachineCode", "UnsLineId"]; + + /// + /// 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. + /// + /// The pasted CSV text. + /// The rows, or a null list with an operator-facing error. + public static (List? Rows, string? Error) Parse(string? csv) + { + if (string.IsNullOrWhiteSpace(csv)) return (null, "CSV is empty."); + + IReadOnlyList 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(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; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs new file mode 100644 index 00000000..d1b2c65c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs @@ -0,0 +1,62 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// R04 S6: the equipment CSV import reads RFC 4180 through the shared CsvParser. +[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(',')"); + } +}