using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
///
/// Gitea #535 — every UNS authoring path applies the segment rule the deploy gate applies.
///
///
///
/// The bug was not that a bad name crashed anything: it saved perfectly, and the next
/// deployment failed with UnsSegmentInvalid. Because a deployment snapshots the whole
/// database, one badly-named area blocked every cluster's deploy, discovered by someone other
/// than whoever typed it.
///
///
/// So these are rejection tests, and each is paired with a name that must still be accepted
/// — a guard that refused everything would satisfy the rejection half alone. They live at the
/// service, not the form, because a form attribute cannot cover the CSV import and this repo has
/// no bUnit to reach a form with.
///
///
[Trait("Category", "Unit")]
public sealed class UnsSegmentGuardTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-segment-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
private static void SeedAreaAndLine(string dbName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode) =>
new(name, machineCode, "LINE-1",
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
/// The two names that actually caused the outage, plus the neighbouring shapes.
public static TheoryData BadNames() => new()
{
"Packaging", // the reported case: a capital
"Line 1", // the reported case: a space
"line_1", // underscore is not in the segment alphabet
"line/1", // a slash would split into two NodeId levels
"", // empty
" ", // whitespace
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 33 chars
};
/// Names that must keep working — the control for every rejection below.
public static TheoryData GoodNames() => new()
{
"packaging",
"line-1",
"a",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 32
UnsSegment.DefaultSegment, // the gate accepts it, so the UI must too
};
// --- Area ---------------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateArea_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, _) = Fresh();
var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
[Theory]
[MemberData(nameof(GoodNames))]
public async Task CreateArea_accepts_a_valid_segment(string name)
{
var (service, _) = Fresh();
(await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null)).Ok.ShouldBeTrue();
}
///
/// Renaming is the other way in, and the one an operator reaches after the name is already
/// deployed — so it is guarded too, and the guard runs before the row is touched.
///
[Fact]
public async Task UpdateArea_rejects_a_rename_to_an_invalid_segment_and_leaves_the_row_alone()
{
var (service, dbName) = Fresh();
await service.CreateAreaAsync("MAIN", "AREA-1", "packaging", null);
var rowVersion = Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion;
(await service.UpdateAreaAsync("AREA-1", "Packaging", null, "MAIN", rowVersion)).Ok.ShouldBeFalse();
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("packaging");
(await service.UpdateAreaAsync("AREA-1", "assembly", null, "MAIN", rowVersion)).Ok.ShouldBeTrue();
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("assembly");
}
// --- Line ---------------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateLine_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", name, null);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
[Fact]
public async Task CreateLine_accepts_a_valid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-2", null)).Ok.ShouldBeTrue();
}
[Fact]
public async Task UpdateLine_rejects_a_rename_to_an_invalid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var rowVersion = Load(dbName).UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion;
(await service.UpdateLineAsync("LINE-1", "Line 1", null, "AREA-1", rowVersion)).Ok.ShouldBeFalse();
(await service.UpdateLineAsync("LINE-1", "line-1", null, "AREA-1", rowVersion)).Ok.ShouldBeTrue();
}
// --- Equipment ----------------------------------------------------------------------------
[Theory]
[MemberData(nameof(BadNames))]
public async Task CreateEquipment_rejects_a_name_the_deploy_gate_would_reject(string name)
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateEquipmentAsync(Input(name, "mc_1"))).Ok.ShouldBeFalse();
}
[Fact]
public async Task CreateEquipment_accepts_a_valid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
(await service.CreateEquipmentAsync(Input("packer-1", "mc_1"))).Ok.ShouldBeTrue();
}
[Fact]
public async Task UpdateEquipment_rejects_a_rename_to_an_invalid_segment()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var created = await service.CreateEquipmentAsync(Input("packer-1", "mc_1"));
created.Ok.ShouldBeTrue();
var row = Load(dbName).Equipment.Single();
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("Packer 1", "mc_1"), row.RowVersion))
.Ok.ShouldBeFalse();
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("packer-2", "mc_1"), row.RowVersion))
.Ok.ShouldBeTrue();
}
// --- CSV import ---------------------------------------------------------------------------
///
/// The bulk path is the one a form attribute could never have covered, and the one where a bad
/// row does the most damage — an import of hundreds lands, and the fleet stops deploying.
///
///
/// A bad name is an error, not a skip: a skip means "you already have this", which would
/// tell the operator the row is present when it is not.
///
[Fact]
public async Task Import_reports_an_invalid_name_as_an_error_and_still_inserts_the_good_rows()
{
var (service, dbName) = Fresh();
SeedAreaAndLine(dbName);
var result = await service.ImportEquipmentAsync(
[
Input("packer-1", "mc_1"),
Input("Packer 2", "mc_2"),
Input("packer-3", "mc_3"),
]);
result.Inserted.ShouldBe(2);
result.Skipped.ShouldBe(0, "a bad name is not a row the operator already has");
result.Errors.ShouldHaveSingleItem().ShouldContain("mc_2");
Load(dbName).Equipment.Select(e => e.Name).OrderBy(n => n)
.ShouldBe(["packer-1", "packer-3"]);
}
private static Configuration.OtOpcUaConfigDbContext Load(string dbName) =>
UnsTreeTestDb.CreateNamed(dbName);
}
/// The shared rule itself — one authority, so the gate and the AdminUI cannot drift (#535).
[Trait("Category", "Unit")]
public sealed class UnsSegmentTests
{
///
/// The attribute pattern the forms use must accept exactly what
/// accepts. They are two expressions of one rule, and a form stricter than the service is a field
/// an operator cannot satisfy; a form looser than it is the #535 bug again.
///
[Theory]
[InlineData("packaging")]
[InlineData("line-1")]
[InlineData("_default")]
[InlineData("Packaging")]
[InlineData("Line 1")]
[InlineData("line_1")]
[InlineData("")]
[InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
public void The_attribute_pattern_agrees_with_IsValid(string candidate)
{
var byAttribute = System.Text.RegularExpressions.Regex.IsMatch(
candidate, UnsSegment.AttributePattern);
byAttribute.ShouldBe(UnsSegment.IsValid(candidate));
}
///
/// alone does NOT admit _default. Pinned because using it
/// on a form — the obvious mistake, and what the equipment form did — makes the UI stricter than
/// the deploy gate, so a _default row becomes uneditable through the AdminUI.
///
[Fact]
public void The_bare_pattern_excludes_the_default_segment_but_IsValid_admits_it()
{
System.Text.RegularExpressions.Regex
.IsMatch(UnsSegment.DefaultSegment, UnsSegment.Pattern).ShouldBeFalse();
UnsSegment.IsValid(UnsSegment.DefaultSegment).ShouldBeTrue();
}
}