- Admin-010: vendor Bootstrap 5.3.3 (CSS + JS bundle + maps + provenance README) under wwwroot/lib/bootstrap and reference local paths from App.razor — Admin no longer pulls Bootstrap from jsDelivr. - Admin-011: swap FleetStatusPoller's three plain dictionaries for ConcurrentDictionary so ResetCache can't race a poll tick. - Admin-012: drop the EquipmentId column from EquipmentCsvImporter (per admin-ui.md — equipment id is system-derived from EquipmentUuid); EquipmentImportBatchService and the textarea placeholder updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
5.8 KiB
C#
172 lines
5.8 KiB
C#
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
|
|
{
|
|
// Admin-012: header no longer includes EquipmentId — that field is system-derived.
|
|
private const string Header =
|
|
"# OtOpcUaCsv v1\n" +
|
|
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
|
|
|
[Fact]
|
|
public void EmptyFile_Throws()
|
|
{
|
|
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(""));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingVersionMarker_Throws()
|
|
{
|
|
var csv = "ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,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,Name,UnsAreaName,UnsLineName\n" +
|
|
"z1,mc,sap,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,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,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
|
"z1,z1,mc,sap,uu,Name,area,line";
|
|
|
|
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidSingleRow_RoundTrips()
|
|
{
|
|
var csv = Header + "\nz-001,MC-1,SAP-1,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,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
|
"z-1,MC,SAP,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,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,u1,N1,A,L1" +
|
|
"\nz-1,MC2,SAP2,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\",\"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,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,u1,N1,A,L1" +
|
|
"\n" +
|
|
"\nz-2,MC,SAP,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()
|
|
{
|
|
// Admin-012: EquipmentId is intentionally absent — derived from EquipmentUuid at finalise time.
|
|
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
|
["ZTag", "MachineCode", "SAPID", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
|
|
|
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
|
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
|
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]);
|
|
}
|
|
}
|