test(adminui): close Phase 6 review test-gaps + Enterprise-delete warning
This commit is contained in:
@@ -80,6 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Delete <span class="mono">@_confirmNode.DisplayName</span>? This cannot be undone.</p>
|
<p>Delete <span class="mono">@_confirmNode.DisplayName</span>? This cannot be undone.</p>
|
||||||
|
@if (_confirmNode.Kind == UnsNodeKind.Enterprise)
|
||||||
|
{
|
||||||
|
<p class="text-danger">This will delete every cluster under this enterprise.</p>
|
||||||
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(_confirmError))
|
@if (!string.IsNullOrWhiteSpace(_confirmError))
|
||||||
{
|
{
|
||||||
<div class="text-danger small mt-2">@_confirmError</div>
|
<div class="text-danger small mt-2">@_confirmError</div>
|
||||||
|
|||||||
@@ -123,4 +123,24 @@ public sealed class TagHistorizeConfigTests
|
|||||||
json.ShouldStartWith("{");
|
json.ShouldStartWith("{");
|
||||||
json.ShouldEndWith("}");
|
json.ShouldEndWith("}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full seam: the history helper and a driver-typed editor compose over the same canonical TagConfig
|
||||||
|
/// blob without clobbering each other. Setting the history keys, round-tripping through the
|
||||||
|
/// OpcUaClient typed editor (FromJson → ToJson), then reading the history keys back recovers them
|
||||||
|
/// intact — and the editor's own FullName field survives the history merge.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void History_keys_survive_a_typed_editor_round_trip()
|
||||||
|
{
|
||||||
|
var withHistory = TagHistorizeConfig.Set(
|
||||||
|
"""{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
|
||||||
|
|
||||||
|
var afterEditor = OpcUaClientTagConfigModel.FromJson(withHistory).ToJson();
|
||||||
|
|
||||||
|
var h = TagHistorizeConfig.Read(afterEditor);
|
||||||
|
h.IsHistorized.ShouldBeTrue();
|
||||||
|
h.HistorianTagname.ShouldBe("TN");
|
||||||
|
OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,63 @@ public sealed class UnsTreeServiceDeleteClusterTests
|
|||||||
verify.ServerClusters.Any(c => c.ClusterId == "CL-DRV").ShouldBeTrue();
|
verify.ServerClusters.Any(c => c.ClusterId == "CL-DRV").ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A cluster with a cluster node refuses deletion with a friendly message and stays put.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCluster_with_cluster_node_refuses_and_keeps_row()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
db.ServerClusters.Add(NewCluster("CL-NODE", "zb"));
|
||||||
|
db.ClusterNodes.Add(new ClusterNode
|
||||||
|
{
|
||||||
|
NodeId = "NODE-CL",
|
||||||
|
ClusterId = "CL-NODE",
|
||||||
|
Host = "host-a",
|
||||||
|
ApplicationUri = "urn:zb:cl-node:a",
|
||||||
|
CreatedBy = "test",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await service.DeleteClusterAsync("CL-NODE");
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error.ShouldContain("CL-NODE");
|
||||||
|
|
||||||
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||||
|
verify.ServerClusters.Any(c => c.ClusterId == "CL-NODE").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A cluster with a namespace refuses deletion with a friendly message and stays put.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCluster_with_namespace_refuses_and_keeps_row()
|
||||||
|
{
|
||||||
|
var (service, dbName) = Fresh();
|
||||||
|
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||||
|
{
|
||||||
|
db.ServerClusters.Add(NewCluster("CL-NS", "zb"));
|
||||||
|
db.Namespaces.Add(new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "NS-CL",
|
||||||
|
ClusterId = "CL-NS",
|
||||||
|
Kind = NamespaceKind.Equipment,
|
||||||
|
NamespaceUri = "urn:zb:cl-ns:equipment",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await service.DeleteClusterAsync("CL-NS");
|
||||||
|
|
||||||
|
result.Ok.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error.ShouldContain("CL-NS");
|
||||||
|
|
||||||
|
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||||
|
verify.ServerClusters.Any(c => c.ClusterId == "CL-NS").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
// ----- DeleteEnterprise -----
|
// ----- DeleteEnterprise -----
|
||||||
|
|
||||||
/// <summary>Deleting an enterprise whose every cluster is child-free removes them all.</summary>
|
/// <summary>Deleting an enterprise whose every cluster is child-free removes them all.</summary>
|
||||||
|
|||||||
+79
@@ -345,6 +345,85 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
|
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The native-alarm <c>historizeToAveva</c> opt-out (bool?, the per-condition durable-AVEVA-write
|
||||||
|
/// gate) is parsed by BOTH equipment-tag producers' <c>ExtractTagAlarm</c> and MUST stay byte-parity:
|
||||||
|
/// for the SAME tag TagConfig carrying <c>alarm.historizeToAveva: true</c> (and <c>: false</c>), the
|
||||||
|
/// live-edit composer (<see cref="Phase7Composer.Compose"/>) and the artifact decoder
|
||||||
|
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) must derive the
|
||||||
|
/// identical <c>EquipmentTagAlarmInfo.HistorizeToAveva</c>. The galaxy-tag parity test above already
|
||||||
|
/// covers the absent ⇒ null case; this pins the explicit-bool branch on both sides.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void Composer_and_artifact_agree_on_alarm_historizeToAveva(bool historizeToAveva)
|
||||||
|
{
|
||||||
|
var ns = new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "ns-eq",
|
||||||
|
ClusterId = "c1",
|
||||||
|
Kind = NamespaceKind.Equipment,
|
||||||
|
NamespaceUri = "urn:eq",
|
||||||
|
};
|
||||||
|
var driver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-modbus",
|
||||||
|
ClusterId = "c1",
|
||||||
|
NamespaceId = "ns-eq",
|
||||||
|
Name = "Modbus1",
|
||||||
|
DriverType = "Modbus",
|
||||||
|
DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||||
|
var equip = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentId = "eq-1",
|
||||||
|
DriverInstanceId = "drv-modbus",
|
||||||
|
UnsLineId = "line-1",
|
||||||
|
Name = "FillingPump",
|
||||||
|
MachineCode = "FILLINGPUMP",
|
||||||
|
};
|
||||||
|
var alarmJson = historizeToAveva ? "true" : "false";
|
||||||
|
var alarmTag = new Tag
|
||||||
|
{
|
||||||
|
TagId = "tag-alarm",
|
||||||
|
DriverInstanceId = "drv-modbus",
|
||||||
|
EquipmentId = "eq-1",
|
||||||
|
FolderPath = null,
|
||||||
|
Name = "Temp",
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = $"{{\"FullName\":\"40001\",\"alarm\":{{\"alarmType\":\"OffNormalAlarm\",\"severity\":700,\"historizeToAveva\":{alarmJson}}}}}",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Side 1: the live-edit composer ----
|
||||||
|
var composed = Phase7Composer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip }, new[] { driver },
|
||||||
|
Array.Empty<ScriptedAlarm>(), new[] { alarmTag }, new[] { ns });
|
||||||
|
|
||||||
|
// ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Namespaces = new[] { new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind } },
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
|
||||||
|
},
|
||||||
|
Tags = new[] { ToSnapshot(alarmTag) },
|
||||||
|
});
|
||||||
|
|
||||||
|
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||||
|
|
||||||
|
// ---- Byte-parity: HistorizeToAveva matches between producers and equals the seeded bool. ----
|
||||||
|
var decodedAlarm = decoded.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull();
|
||||||
|
var composedAlarm = composed.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull();
|
||||||
|
decodedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva);
|
||||||
|
composedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva);
|
||||||
|
decodedAlarm.ShouldBe(composedAlarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
|
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
|
||||||
/// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields.</summary>
|
/// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields.</summary>
|
||||||
private static object ToSnapshot(Tag t) => new
|
private static object ToSnapshot(Tag t) => new
|
||||||
|
|||||||
Reference in New Issue
Block a user