From 560a961cca5b4e8657b8321b044a264700dc2fbf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 10:09:47 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.4=20Stream=20A=20+=20B=20data=20layer?= =?UTF-8?q?=20=E2=80=94=20UnsImpactAnalyzer=20+=20EquipmentCsvImporter=20(?= =?UTF-8?q?parser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the pure-logic data layer of Phase 6.4. Blazor UI pieces (UnsTab drag/drop, CSV import modal, preview table, FinaliseImportBatch txn, staging tables) are deferred to visual-compliance follow-ups (tasks #153, #155, #157). Admin.Services additions: - UnsImpactAnalyzer.Analyze(snapshot, move) — pure-function, no I/O. Three move variants: LineMove, AreaRename, LineMerge. Returns UnsImpactPreview with AffectedEquipmentCount + AffectedTagCount + CascadeWarnings + RevisionToken + HumanReadableSummary the Admin UI shows in the confirm modal. Cross-cluster moves rejected with CrossClusterMoveRejectedException per decision #82. Missing source/target throws UnsMoveValidationException. Surfaces sibling-line same-name ambiguity as a cascade warning. - DraftRevisionToken — opaque revision fingerprint. Preview captures the token; Confirm compares it. The 409-concurrent-edit UX plumbs through on the Razor-page follow-up (task #153). Matches(other) is null-safe. - UnsTreeSnapshot + UnsAreaSummary + UnsLineSummary — snapshot shape the caller hands to the analyzer. Tests build them in-memory without a DB. - EquipmentCsvImporter.Parse(csvText) — RFC 4180 CSV parser per decision #95. Version-marker contract: line 1 must be "# OtOpcUaCsv v1" (future shapes bump the version). Required columns from decision #117 + optional columns from decision #139. Rejects unknown columns, duplicate column names, blank required fields, duplicate ZTags within the file. Quoted-field handling supports embedded commas + escaped "" quotes. Returns EquipmentCsvParseResult { AcceptedRows, RejectedRows } so the preview modal renders accept/reject counts without re-parsing. Tests (22 new, all pass): - UnsImpactAnalyzerTests (9): line move counts equipment + tags; cross- cluster throws; unknown source/target throws validation; ambiguous same- name target raises warning; area rename sums across lines; line merge cross-area warns; same-area merge no warning; DraftRevisionToken matches semantics. - EquipmentCsvImporterTests (13): empty file throws; missing version marker; missing required column; unknown column; duplicate column; valid single row round-trips; optional columns populate when present; blank required field rejects row; duplicate ZTag rejects second; RFC 4180 quoted fields with commas + escaped quotes; mismatched column count rejects; blank lines between rows ignored; required + optional column constants match decisions #117 + #139 exactly. Full solution dotnet test: 1159 passing (Phase 6.3 = 1137, Phase 6.4 A+B data = +22). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/EquipmentCsvImporter.cs | 259 ++++++++++++++++++ .../Services/UnsImpactAnalyzer.cs | 213 ++++++++++++++ .../EquipmentCsvImporterTests.cs | 169 ++++++++++++ .../UnsImpactAnalyzerTests.cs | 173 ++++++++++++ 4 files changed, 814 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs new file mode 100644 index 0000000..99a8c58 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// RFC 4180 CSV parser for equipment import per decision #95 and Phase 6.4 Stream B.1. +/// Produces a validated the caller (CSV import +/// modal + staging tables) consumes. Pure-parser concern — no DB access, no staging +/// writes; those live in the follow-up Stream B.2 work. +/// +/// +/// Header contract: line 1 must be exactly # OtOpcUaCsv v1 (version +/// marker). Line 2 is the column header row. Unknown columns are rejected; required +/// columns must all be present. The version bump handshake lets future shapes parse +/// without ambiguity — v2 files go through a different parser variant. +/// +/// Required columns per decision #117: ZTag, MachineCode, SAPID, +/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName. +/// +/// Optional columns per decision #139: Manufacturer, Model, SerialNumber, +/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, +/// ManufacturerUri, DeviceManualUri. +/// +/// Row validation: blank required field → rejected; duplicate ZTag within +/// the same file → rejected. Duplicate against the DB isn't detected here — the +/// staged-import finalize step (Stream B.4) catches that. +/// +public static class EquipmentCsvImporter +{ + public const string VersionMarker = "# OtOpcUaCsv v1"; + + public static IReadOnlyList RequiredColumns { get; } = new[] + { + "ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", + "Name", "UnsAreaName", "UnsLineName", + }; + + public static IReadOnlyList OptionalColumns { get; } = new[] + { + "Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision", + "YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri", + }; + + public static EquipmentCsvParseResult Parse(string csvText) + { + ArgumentNullException.ThrowIfNull(csvText); + + var rows = SplitLines(csvText); + if (rows.Count == 0) + throw new InvalidCsvFormatException("CSV is empty."); + + if (!string.Equals(rows[0].Trim(), VersionMarker, StringComparison.Ordinal)) + throw new InvalidCsvFormatException( + $"CSV header line 1 must be exactly '{VersionMarker}' — got '{rows[0]}'. " + + "Files without the version marker are rejected so future-format files don't parse ambiguously."); + + if (rows.Count < 2) + throw new InvalidCsvFormatException("CSV has no column header row (line 2) or data rows."); + + var headerCells = SplitCsvRow(rows[1]); + ValidateHeader(headerCells); + + var accepted = new List(); + var rejected = new List(); + var ztagsSeen = new HashSet(StringComparer.OrdinalIgnoreCase); + var colIndex = headerCells + .Select((name, idx) => (name, idx)) + .ToDictionary(t => t.name, t => t.idx, StringComparer.OrdinalIgnoreCase); + + for (var i = 2; i < rows.Count; i++) + { + if (string.IsNullOrWhiteSpace(rows[i])) continue; + + try + { + var cells = SplitCsvRow(rows[i]); + if (cells.Length != headerCells.Length) + { + rejected.Add(new EquipmentCsvRowError( + LineNumber: i + 1, + Reason: $"Column count {cells.Length} != header count {headerCells.Length}.")); + continue; + } + + var row = BuildRow(cells, colIndex); + var missing = RequiredColumns.Where(c => string.IsNullOrWhiteSpace(GetCell(row, c))).ToList(); + if (missing.Count > 0) + { + rejected.Add(new EquipmentCsvRowError(i + 1, $"Blank required column(s): {string.Join(", ", missing)}")); + continue; + } + + if (!ztagsSeen.Add(row.ZTag)) + { + rejected.Add(new EquipmentCsvRowError(i + 1, $"Duplicate ZTag '{row.ZTag}' within file.")); + continue; + } + + accepted.Add(row); + } + catch (InvalidCsvFormatException ex) + { + rejected.Add(new EquipmentCsvRowError(i + 1, ex.Message)); + } + } + + return new EquipmentCsvParseResult(accepted, rejected); + } + + private static void ValidateHeader(string[] headerCells) + { + var seen = new HashSet(headerCells, StringComparer.OrdinalIgnoreCase); + + // Missing required + var missingRequired = RequiredColumns.Where(r => !seen.Contains(r)).ToList(); + if (missingRequired.Count > 0) + throw new InvalidCsvFormatException($"Header is missing required column(s): {string.Join(", ", missingRequired)}"); + + // Unknown columns (not in required ∪ optional) + var known = new HashSet(RequiredColumns.Concat(OptionalColumns), StringComparer.OrdinalIgnoreCase); + var unknown = headerCells.Where(c => !known.Contains(c)).ToList(); + if (unknown.Count > 0) + throw new InvalidCsvFormatException( + $"Header has unknown column(s): {string.Join(", ", unknown)}. " + + "Bump the version marker to define a new shape before adding columns."); + + // Duplicates + var dupe = headerCells.GroupBy(c => c, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1); + if (dupe is not null) + throw new InvalidCsvFormatException($"Header has duplicate column '{dupe.Key}'."); + } + + private static EquipmentCsvRow BuildRow(string[] cells, Dictionary colIndex) => new() + { + ZTag = cells[colIndex["ZTag"]], + MachineCode = cells[colIndex["MachineCode"]], + SAPID = cells[colIndex["SAPID"]], + EquipmentId = cells[colIndex["EquipmentId"]], + EquipmentUuid = cells[colIndex["EquipmentUuid"]], + Name = cells[colIndex["Name"]], + UnsAreaName = cells[colIndex["UnsAreaName"]], + UnsLineName = cells[colIndex["UnsLineName"]], + Manufacturer = colIndex.TryGetValue("Manufacturer", out var mi) ? cells[mi] : null, + Model = colIndex.TryGetValue("Model", out var moi) ? cells[moi] : null, + SerialNumber = colIndex.TryGetValue("SerialNumber", out var si) ? cells[si] : null, + HardwareRevision = colIndex.TryGetValue("HardwareRevision", out var hi) ? cells[hi] : null, + SoftwareRevision = colIndex.TryGetValue("SoftwareRevision", out var swi) ? cells[swi] : null, + YearOfConstruction = colIndex.TryGetValue("YearOfConstruction", out var yi) ? cells[yi] : null, + AssetLocation = colIndex.TryGetValue("AssetLocation", out var ai) ? cells[ai] : null, + ManufacturerUri = colIndex.TryGetValue("ManufacturerUri", out var mui) ? cells[mui] : null, + DeviceManualUri = colIndex.TryGetValue("DeviceManualUri", out var dui) ? cells[dui] : null, + }; + + private static string GetCell(EquipmentCsvRow row, string colName) => colName switch + { + "ZTag" => row.ZTag, + "MachineCode" => row.MachineCode, + "SAPID" => row.SAPID, + "EquipmentId" => row.EquipmentId, + "EquipmentUuid" => row.EquipmentUuid, + "Name" => row.Name, + "UnsAreaName" => row.UnsAreaName, + "UnsLineName" => row.UnsLineName, + _ => string.Empty, + }; + + /// Split the raw text on line boundaries. Handles \r\n + \n + \r. + private static List SplitLines(string csv) => + csv.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList(); + + /// Split one CSV row with RFC 4180 quoted-field handling. + private static string[] SplitCsvRow(string row) + { + var cells = new List(); + var sb = new StringBuilder(); + var inQuotes = false; + + for (var i = 0; i < row.Length; i++) + { + var ch = row[i]; + if (inQuotes) + { + if (ch == '"') + { + // Escaped quote "" inside quoted field. + if (i + 1 < row.Length && row[i + 1] == '"') + { + sb.Append('"'); + i++; + } + else + { + inQuotes = false; + } + } + else + { + sb.Append(ch); + } + } + else + { + if (ch == ',') + { + cells.Add(sb.ToString()); + sb.Clear(); + } + else if (ch == '"' && sb.Length == 0) + { + inQuotes = true; + } + else + { + sb.Append(ch); + } + } + } + + cells.Add(sb.ToString()); + return cells.ToArray(); + } +} + +/// One parsed equipment row with required + optional fields. +public sealed class EquipmentCsvRow +{ + // Required (decision #117) + public required string ZTag { get; init; } + public required string MachineCode { get; init; } + public required string SAPID { get; init; } + public required string EquipmentId { get; init; } + public required string EquipmentUuid { get; init; } + public required string Name { get; init; } + public required string UnsAreaName { get; init; } + public required string UnsLineName { get; init; } + + // Optional (decision #139 — OPC 40010 Identification fields) + public string? Manufacturer { get; init; } + public string? Model { get; init; } + public string? SerialNumber { get; init; } + public string? HardwareRevision { get; init; } + public string? SoftwareRevision { get; init; } + public string? YearOfConstruction { get; init; } + public string? AssetLocation { get; init; } + public string? ManufacturerUri { get; init; } + public string? DeviceManualUri { get; init; } +} + +/// One row-level rejection captured by the parser. Line-number is 1-based in the source file. +public sealed record EquipmentCsvRowError(int LineNumber, string Reason); + +/// Parser output — accepted rows land in staging; rejected rows surface in the preview modal. +public sealed record EquipmentCsvParseResult( + IReadOnlyList AcceptedRows, + IReadOnlyList RejectedRows); + +/// Thrown for file-level format problems (missing version marker, bad header, etc.). +public sealed class InvalidCsvFormatException(string message) : Exception(message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs new file mode 100644 index 0000000..df76e45 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs @@ -0,0 +1,213 @@ +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given +/// a plus a snapshot of the draft's UNS tree and its +/// equipment + tag counts, returns an the Admin UI shows +/// in a confirmation modal before committing the move. +/// +/// +/// Stateless + deterministic — testable without EF or a live draft. The caller +/// (Razor page) loads the draft's snapshot via the normal Configuration services, passes +/// it in, and the analyzer counts + categorises the impact. The returned +/// is the token the caller must re-check at +/// confirm time; a mismatch means another operator mutated the draft between preview + +/// confirm and the operation needs to be refreshed (decision on concurrent-edit safety +/// in Phase 6.4 Scope). +/// +/// Cross-cluster moves are rejected here (decision #82) — equipment is +/// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow +/// toast instead. +/// +public static class UnsImpactAnalyzer +{ + /// Run the analyzer. Returns a populated preview or throws for invalid operations. + public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move) + { + ArgumentNullException.ThrowIfNull(snapshot); + ArgumentNullException.ThrowIfNull(move); + + // Cross-cluster guard — the analyzer refuses rather than silently re-homing. + if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase)) + throw new CrossClusterMoveRejectedException( + "Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " + + "across clusters; drag/drop rejected."); + + return move.Kind switch + { + UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move), + UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move), + UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move), + _ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"), + }; + } + + private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move) + { + var line = snapshot.FindLine(move.SourceLineId!) + ?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}."); + + var targetArea = snapshot.FindArea(move.TargetAreaId!) + ?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}."); + + var warnings = new List(); + if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase)) + warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move."); + + // If the target area has a line with the same display name as the mover, warn about + // visual ambiguity even though the IDs differ (operators frequently reuse line names). + if (targetArea.LineIds.Any(lid => + snapshot.FindLine(lid) is { } sibling && + string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) && + !string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase))) + { + warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move."); + } + + return new UnsImpactPreview + { + AffectedEquipmentCount = line.EquipmentCount, + AffectedTagCount = line.TagCount, + CascadeWarnings = warnings, + RevisionToken = snapshot.RevisionToken, + HumanReadableSummary = + $"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " + + $"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.", + }; + } + + private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move) + { + var area = snapshot.FindArea(move.SourceAreaId!) + ?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}."); + + var affectedEquipment = area.LineIds + .Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0) + .Sum(); + var affectedTags = area.LineIds + .Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0) + .Sum(); + + return new UnsImpactPreview + { + AffectedEquipmentCount = affectedEquipment, + AffectedTagCount = affectedTags, + CascadeWarnings = [], + RevisionToken = snapshot.RevisionToken, + HumanReadableSummary = + $"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " + + $"{affectedEquipment} equipment / {affectedTags} tags.", + }; + } + + private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move) + { + var src = snapshot.FindLine(move.SourceLineId!) + ?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found."); + var dst = snapshot.FindLine(move.TargetLineId!) + ?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found."); + + var warnings = new List(); + if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId, + snapshot.FindAreaByLineId(dst.LineId)?.AreaId, + StringComparison.OrdinalIgnoreCase)) + { + warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area."); + } + + return new UnsImpactPreview + { + AffectedEquipmentCount = src.EquipmentCount, + AffectedTagCount = src.TagCount, + CascadeWarnings = warnings, + RevisionToken = snapshot.RevisionToken, + HumanReadableSummary = + $"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " + + $"The source line is deleted at commit.", + }; + } +} + +/// Kind of UNS structural move the analyzer understands. +public enum UnsMoveKind +{ + /// Drag a whole line from one area to another. + LineMove, + + /// Rename an area (cascades to the UNS paths of every equipment + tag below it). + AreaRename, + + /// Merge two lines into one; source line's equipment + tags are re-parented. + LineMerge, +} + +/// One UNS structural move request. +/// Move variant — selects which source + target fields are required. +/// Cluster of the source node. Must match (decision #82). +/// Cluster of the target node. +/// Source area id for . +/// Source line id for / . +/// Target area id for . +/// Target line id for . +/// New display name for . +public sealed record UnsMoveOperation( + UnsMoveKind Kind, + string SourceClusterId, + string TargetClusterId, + string? SourceAreaId = null, + string? SourceLineId = null, + string? TargetAreaId = null, + string? TargetLineId = null, + string? NewName = null); + +/// Snapshot of the UNS tree + counts the analyzer walks. +public sealed class UnsTreeSnapshot +{ + public required long DraftGenerationId { get; init; } + public required DraftRevisionToken RevisionToken { get; init; } + public required IReadOnlyList Areas { get; init; } + public required IReadOnlyList Lines { get; init; } + + public UnsAreaSummary? FindArea(string areaId) => + Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase)); + + public UnsLineSummary? FindLine(string lineId) => + Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase)); + + public UnsAreaSummary? FindAreaByLineId(string lineId) => + Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase)); +} + +public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList LineIds); + +public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount); + +/// +/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it +/// in the . Confirm compares the token against +/// the draft's live value; mismatch means another operator mutated the draft between +/// preview + commit — raise 409 Conflict / refresh-required in the UI. +/// +public sealed record DraftRevisionToken(string Value) +{ + /// Compare two tokens for equality; null-safe. + public bool Matches(DraftRevisionToken? other) => + other is not null && + string.Equals(Value, other.Value, StringComparison.Ordinal); +} + +/// Output of . +public sealed class UnsImpactPreview +{ + public required int AffectedEquipmentCount { get; init; } + public required int AffectedTagCount { get; init; } + public required IReadOnlyList CascadeWarnings { get; init; } + public required DraftRevisionToken RevisionToken { get; init; } + public required string HumanReadableSummary { get; init; } +} + +/// Thrown when a move targets a different cluster than the source (decision #82). +public sealed class CrossClusterMoveRejectedException(string message) : Exception(message); + +/// Thrown when the move operation references a source / target that doesn't exist in the draft. +public sealed class UnsMoveValidationException(string message) : Exception(message); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs new file mode 100644 index 0000000..c3a32f1 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs @@ -0,0 +1,169 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class EquipmentCsvImporterTests +{ + private const string Header = + "# OtOpcUaCsv v1\n" + + "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName"; + + [Fact] + public void EmptyFile_Throws() + { + Should.Throw(() => EquipmentCsvImporter.Parse("")); + } + + [Fact] + public void MissingVersionMarker_Throws() + { + var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x,x"; + + var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv)); + ex.Message.ShouldContain("# OtOpcUaCsv v1"); + } + + [Fact] + public void MissingRequiredColumn_Throws() + { + var csv = "# OtOpcUaCsv v1\n" + + "ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" + + "z1,mc,sap,eq1,Name1,area,line"; + + var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv)); + ex.Message.ShouldContain("EquipmentUuid"); + } + + [Fact] + public void UnknownColumn_Throws() + { + var csv = Header + ",WeirdColumn\nz1,mc,sap,eq1,uu,Name1,area,line,value"; + + var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv)); + ex.Message.ShouldContain("WeirdColumn"); + } + + [Fact] + public void DuplicateColumn_Throws() + { + var csv = "# OtOpcUaCsv v1\n" + + "ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" + + "z1,z1,mc,sap,eq,uu,Name,area,line"; + + Should.Throw(() => EquipmentCsvImporter.Parse(csv)); + } + + [Fact] + public void ValidSingleRow_RoundTrips() + { + var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,uuid-1,Oven-A,Warsaw,Line-1"; + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.Count.ShouldBe(1); + result.RejectedRows.ShouldBeEmpty(); + var row = result.AcceptedRows[0]; + row.ZTag.ShouldBe("z-001"); + row.MachineCode.ShouldBe("MC-1"); + row.Name.ShouldBe("Oven-A"); + row.UnsLineName.ShouldBe("Line-1"); + } + + [Fact] + public void OptionalColumns_Populated_WhenPresent() + { + var csv = "# OtOpcUaCsv v1\n" + + "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" + + "z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual"; + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.Count.ShouldBe(1); + var row = result.AcceptedRows[0]; + row.Manufacturer.ShouldBe("Siemens"); + row.Model.ShouldBe("S7-1500"); + row.SerialNumber.ShouldBe("SN123"); + row.YearOfConstruction.ShouldBe("2023"); + row.ManufacturerUri.ShouldBe("https://siemens.example"); + } + + [Fact] + public void BlankRequiredField_Rejects_Row() + { + var csv = Header + "\nz-1,MC,SAP,eq,uuid,,Warsaw,Line1"; // Name blank + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.ShouldBeEmpty(); + result.RejectedRows.Count.ShouldBe(1); + result.RejectedRows[0].Reason.ShouldContain("Name"); + } + + [Fact] + public void DuplicateZTag_Rejects_SecondRow() + { + var csv = Header + + "\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" + + "\nz-1,MC2,SAP2,eq2,u2,N2,A,L1"; + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.Count.ShouldBe(1); + result.RejectedRows.Count.ShouldBe(1); + result.RejectedRows[0].Reason.ShouldContain("Duplicate ZTag"); + } + + [Fact] + public void QuotedField_With_CommaAndQuote_Parses_Correctly() + { + // RFC 4180: "" inside a quoted field is an escaped quote. + var csv = Header + + "\n\"z-1\",\"MC\",\"SAP,with,commas\",\"eq\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\""; + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.Count.ShouldBe(1); + result.AcceptedRows[0].SAPID.ShouldBe("SAP,with,commas"); + result.AcceptedRows[0].Name.ShouldBe("Oven \"Ultra\""); + } + + [Fact] + public void MismatchedColumnCount_Rejects_Row() + { + var csv = Header + "\nz-1,MC,SAP,eq,uuid,Name,Warsaw"; // missing UnsLineName cell + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.ShouldBeEmpty(); + result.RejectedRows.Count.ShouldBe(1); + result.RejectedRows[0].Reason.ShouldContain("Column count"); + } + + [Fact] + public void BlankLines_BetweenRows_AreIgnored() + { + var csv = Header + + "\nz-1,MC,SAP,eq1,u1,N1,A,L1" + + "\n" + + "\nz-2,MC,SAP,eq2,u2,N2,A,L1"; + + var result = EquipmentCsvImporter.Parse(csv); + + result.AcceptedRows.Count.ShouldBe(2); + result.RejectedRows.ShouldBeEmpty(); + } + + [Fact] + public void Header_Constants_Match_Decision_117_and_139() + { + EquipmentCsvImporter.RequiredColumns.ShouldBe( + ["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]); + + EquipmentCsvImporter.OptionalColumns.ShouldBe( + ["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision", + "YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs new file mode 100644 index 0000000..0c92769 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs @@ -0,0 +1,173 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class UnsImpactAnalyzerTests +{ + private static UnsTreeSnapshot TwoAreaSnapshot() => new() + { + DraftGenerationId = 1, + RevisionToken = new DraftRevisionToken("rev-1"), + Areas = + [ + new UnsAreaSummary("area-pack", "Packaging", ["line-oven", "line-wrap"]), + new UnsAreaSummary("area-asm", "Assembly", ["line-weld"]), + ], + Lines = + [ + new UnsLineSummary("line-oven", "Oven-2", EquipmentCount: 14, TagCount: 237), + new UnsLineSummary("line-wrap", "Wrapper", EquipmentCount: 3, TagCount: 40), + new UnsLineSummary("line-weld", "Welder", EquipmentCount: 5, TagCount: 80), + ], + }; + + [Fact] + public void LineMove_Counts_Affected_Equipment_And_Tags() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMove, + SourceClusterId: "c1", TargetClusterId: "c1", + SourceLineId: "line-oven", + TargetAreaId: "area-asm"); + + var preview = UnsImpactAnalyzer.Analyze(snapshot, move); + + preview.AffectedEquipmentCount.ShouldBe(14); + preview.AffectedTagCount.ShouldBe(237); + preview.RevisionToken.Value.ShouldBe("rev-1"); + preview.HumanReadableSummary.ShouldContain("'Oven-2'"); + preview.HumanReadableSummary.ShouldContain("'Assembly'"); + } + + [Fact] + public void CrossCluster_LineMove_Throws() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMove, + SourceClusterId: "c1", TargetClusterId: "c2", + SourceLineId: "line-oven", + TargetAreaId: "area-asm"); + + Should.Throw( + () => UnsImpactAnalyzer.Analyze(snapshot, move)); + } + + [Fact] + public void LineMove_With_UnknownSource_Throws_Validation() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + UnsMoveKind.LineMove, "c1", "c1", + SourceLineId: "line-does-not-exist", + TargetAreaId: "area-asm"); + + Should.Throw( + () => UnsImpactAnalyzer.Analyze(snapshot, move)); + } + + [Fact] + public void LineMove_With_UnknownTarget_Throws_Validation() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + UnsMoveKind.LineMove, "c1", "c1", + SourceLineId: "line-oven", + TargetAreaId: "area-nowhere"); + + Should.Throw( + () => UnsImpactAnalyzer.Analyze(snapshot, move)); + } + + [Fact] + public void LineMove_To_Area_WithSameName_Warns_AboutAmbiguity() + { + var snapshot = new UnsTreeSnapshot + { + DraftGenerationId = 1, + RevisionToken = new DraftRevisionToken("rev-1"), + Areas = + [ + new UnsAreaSummary("area-a", "Packaging", ["line-1"]), + new UnsAreaSummary("area-b", "Assembly", ["line-2"]), + ], + Lines = + [ + new UnsLineSummary("line-1", "Oven", 10, 100), + new UnsLineSummary("line-2", "Oven", 5, 50), + ], + }; + var move = new UnsMoveOperation( + UnsMoveKind.LineMove, "c1", "c1", + SourceLineId: "line-1", + TargetAreaId: "area-b"); + + var preview = UnsImpactAnalyzer.Analyze(snapshot, move); + + preview.CascadeWarnings.ShouldContain(w => w.Contains("already has a line named 'Oven'")); + } + + [Fact] + public void AreaRename_Cascades_AcrossAllLines() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.AreaRename, + SourceClusterId: "c1", TargetClusterId: "c1", + SourceAreaId: "area-pack", + NewName: "Packaging-West"); + + var preview = UnsImpactAnalyzer.Analyze(snapshot, move); + + preview.AffectedEquipmentCount.ShouldBe(14 + 3, "sum of lines in 'Packaging'"); + preview.AffectedTagCount.ShouldBe(237 + 40); + preview.HumanReadableSummary.ShouldContain("'Packaging-West'"); + } + + [Fact] + public void LineMerge_CrossArea_Warns() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMerge, + SourceClusterId: "c1", TargetClusterId: "c1", + SourceLineId: "line-oven", + TargetLineId: "line-weld"); + + var preview = UnsImpactAnalyzer.Analyze(snapshot, move); + + preview.AffectedEquipmentCount.ShouldBe(14); + preview.CascadeWarnings.ShouldContain(w => w.Contains("different areas")); + } + + [Fact] + public void LineMerge_SameArea_NoWarning() + { + var snapshot = TwoAreaSnapshot(); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMerge, + SourceClusterId: "c1", TargetClusterId: "c1", + SourceLineId: "line-oven", + TargetLineId: "line-wrap"); + + var preview = UnsImpactAnalyzer.Analyze(snapshot, move); + + preview.CascadeWarnings.ShouldBeEmpty(); + } + + [Fact] + public void DraftRevisionToken_Matches_OnEqualValues() + { + var a = new DraftRevisionToken("rev-1"); + var b = new DraftRevisionToken("rev-1"); + var c = new DraftRevisionToken("rev-2"); + + a.Matches(b).ShouldBeTrue(); + a.Matches(c).ShouldBeFalse(); + a.Matches(null).ShouldBeFalse(); + } +}