Phase 6.4 Stream A + B data layer — UnsImpactAnalyzer + EquipmentCsvImporter (parser)
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) <noreply@anthropic.com>
This commit is contained in:
169
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs
Normal file
169
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs
Normal file
@@ -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<InvalidCsvFormatException>(() => 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<InvalidCsvFormatException>(() => 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<InvalidCsvFormatException>(() => 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<InvalidCsvFormatException>(() => 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<InvalidCsvFormatException>(() => 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"]);
|
||||
}
|
||||
}
|
||||
173
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs
Normal file
173
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs
Normal file
@@ -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<CrossClusterMoveRejectedException>(
|
||||
() => 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<UnsMoveValidationException>(
|
||||
() => 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<UnsMoveValidationException>(
|
||||
() => 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user