refactor(adminui): strip alias/relay machinery from UnsTreeService + EquipmentPage; Galaxy tags use standard TagModal
This commit is contained in:
@@ -1,466 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the read-side surface for Galaxy alias tags: that
|
||||
/// <see cref="UnsTreeService.LoadGalaxyGatewaysForEquipmentAsync"/> finds the
|
||||
/// <c>GalaxyMxGateway</c> drivers in the equipment's cluster (and only those), and that
|
||||
/// <see cref="UnsTreeService.LoadTagsForEquipmentAsync"/> flags alias tags (those bound to a Galaxy
|
||||
/// gateway) with <c>IsAlias = true</c> and a <c>Source</c> derived from the tag's <c>TagConfig</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceAliasTagTests
|
||||
{
|
||||
private const string ClusterId = "MAIN";
|
||||
private const string EquipmentId = "EQ-ALIAS-1";
|
||||
private const string GatewayDriverId = "DRV-GALAXY";
|
||||
private const string ModbusDriverId = "DRV-MODBUS";
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a cluster with: a SystemPlatform namespace + GalaxyMxGateway driver, an Equipment-kind
|
||||
/// namespace + Modbus driver, and an area→line→equipment path. Returns the InMemory db name.
|
||||
/// </summary>
|
||||
private static string SeedCluster(bool withGalaxyGateway = true)
|
||||
{
|
||||
var dbName = $"uns-alias-{Guid.NewGuid():N}";
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
Name = "Main",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = EquipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-1",
|
||||
Name = "machine-1",
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
|
||||
// SystemPlatform namespace hosting the Galaxy gateway.
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-SP",
|
||||
ClusterId = ClusterId,
|
||||
Kind = NamespaceKind.SystemPlatform,
|
||||
NamespaceUri = "urn:zb:sp",
|
||||
});
|
||||
if (withGalaxyGateway)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = "NS-SP",
|
||||
Name = "galaxy gateway",
|
||||
DriverType = "GalaxyMxGateway",
|
||||
DriverConfig = "{\"Galaxy\":{}}",
|
||||
});
|
||||
}
|
||||
|
||||
// Equipment-kind namespace hosting an ordinary (non-Galaxy) driver.
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-EQ",
|
||||
ClusterId = ClusterId,
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:eq",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = ModbusDriverId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = "NS-EQ",
|
||||
Name = "modbus driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
return dbName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gateway lookup returns exactly the GalaxyMxGateway driver (with its id, a display string,
|
||||
/// and its DriverConfig) and never the cluster's Modbus driver.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadGalaxyGatewaysForEquipment_returns_only_galaxy_gateway()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
|
||||
|
||||
gateways.Count.ShouldBe(1);
|
||||
gateways[0].DriverInstanceId.ShouldBe(GatewayDriverId);
|
||||
gateways[0].Display.ShouldContain(GatewayDriverId);
|
||||
gateways[0].Display.ShouldContain("galaxy gateway");
|
||||
gateways[0].DriverConfig.ShouldBe("{\"Galaxy\":{}}");
|
||||
gateways.ShouldNotContain(g => g.DriverInstanceId == ModbusDriverId);
|
||||
}
|
||||
|
||||
/// <summary>An equipment whose cluster has no GalaxyMxGateway driver yields an empty list.</summary>
|
||||
[Fact]
|
||||
public async Task LoadGalaxyGatewaysForEquipment_returns_empty_when_no_gateway()
|
||||
{
|
||||
var dbName = SeedCluster(withGalaxyGateway: false);
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
|
||||
|
||||
gateways.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag bound to the Galaxy gateway is an alias (<c>IsAlias = true</c>, <c>Source</c> derived from
|
||||
/// its TagConfig FullName); a tag bound to the Modbus driver is not (<c>IsAlias = false</c>,
|
||||
/// <c>Source = null</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadTagsForEquipment_flags_galaxy_alias_rows()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-ALIAS",
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "aliased-speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
|
||||
});
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-NORMAL",
|
||||
DriverInstanceId = ModbusDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "raw-speed",
|
||||
DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var rows = await service.LoadTagsForEquipmentAsync(EquipmentId);
|
||||
|
||||
var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS");
|
||||
alias.IsAlias.ShouldBeTrue();
|
||||
alias.Source.ShouldBe("galaxy:TestMachine_020.Speed");
|
||||
|
||||
var normal = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-NORMAL");
|
||||
normal.IsAlias.ShouldBeFalse();
|
||||
normal.Source.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A GalaxyMxGateway-bound tag whose TagConfig carries no <c>FullName</c> (e.g. <c>"{}"</c>) is
|
||||
/// still an alias (<c>IsAlias = true</c>) but has a <c>null</c> Source — the display should not
|
||||
/// show the literal <c>"galaxy:"</c> string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadTagsForEquipment_alias_with_no_FullName_has_null_Source()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-ALIAS-NONAME",
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "unconfigured-alias",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var rows = await service.LoadTagsForEquipmentAsync(EquipmentId);
|
||||
|
||||
var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS-NONAME");
|
||||
alias.IsAlias.ShouldBeTrue();
|
||||
alias.Source.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Write-side: CreateAliasTagAsync / UpdateAliasTagAsync (T5) ----
|
||||
|
||||
private const string SecondClusterId = "ALT";
|
||||
private const string SecondGatewayDriverId = "DRV-GALAXY-ALT";
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a second cluster (no UNS hierarchy needed) carrying its own GalaxyMxGateway driver, so a
|
||||
/// cross-cluster guard test can reference a gateway that is NOT in the equipment's cluster.
|
||||
/// </summary>
|
||||
private static void SeedSecondClusterGateway(string dbName)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = SecondClusterId,
|
||||
Name = "Alt",
|
||||
Enterprise = "zb",
|
||||
Site = "krakow",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-SP-ALT",
|
||||
ClusterId = SecondClusterId,
|
||||
Kind = NamespaceKind.SystemPlatform,
|
||||
NamespaceUri = "urn:zb:sp:alt",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = SecondGatewayDriverId,
|
||||
ClusterId = SecondClusterId,
|
||||
NamespaceId = "NS-SP-ALT",
|
||||
Name = "alt galaxy gateway",
|
||||
DriverType = "GalaxyMxGateway",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static AliasTagInput Input(
|
||||
string tagId = "TAG-NEW-ALIAS",
|
||||
string name = "speed-alias",
|
||||
string driverInstanceId = GatewayDriverId,
|
||||
string dataType = "Float",
|
||||
TagAccessLevel accessLevel = TagAccessLevel.Read,
|
||||
string fullName = "TestMachine_020.Speed") =>
|
||||
new(tagId, name, driverInstanceId, dataType, accessLevel, fullName);
|
||||
|
||||
/// <summary>
|
||||
/// A valid create persists a Galaxy-gateway-bound, equipment-scoped alias Tag: <c>FolderPath</c> is
|
||||
/// null, the supplied AccessLevel is honoured, and the TagConfig is the <c>{"FullName":…}</c> envelope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateAliasTag_persists_galaxy_alias_row()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.CreateAliasTagAsync(
|
||||
EquipmentId, Input(accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Speed"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var tag = db.Tags.Single(t => t.TagId == "TAG-NEW-ALIAS");
|
||||
tag.DriverInstanceId.ShouldBe(GatewayDriverId);
|
||||
tag.EquipmentId.ShouldBe(EquipmentId);
|
||||
tag.FolderPath.ShouldBeNull();
|
||||
tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
|
||||
tag.Name.ShouldBe("speed-alias");
|
||||
tag.DataType.ShouldBe("Float");
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
|
||||
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed");
|
||||
tag.TagConfig.ShouldContain("FullName");
|
||||
tag.TagConfig.ShouldContain("TestMachine_020.Speed");
|
||||
tag.WriteIdempotent.ShouldBeFalse();
|
||||
tag.PollGroupId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>An empty/whitespace Galaxy reference is rejected before anything is written.</summary>
|
||||
[Fact]
|
||||
public async Task CreateAliasTag_rejects_empty_reference()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.CreateAliasTagAsync(EquipmentId, Input(fullName: " "));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("reference");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.TagId == "TAG-NEW-ALIAS").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Binding the alias to a non-Galaxy driver (the cluster's Modbus driver) is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task CreateAliasTag_rejects_non_galaxy_driver()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.CreateAliasTagAsync(
|
||||
EquipmentId, Input(driverInstanceId: ModbusDriverId));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("Galaxy gateway");
|
||||
}
|
||||
|
||||
/// <summary>A Galaxy gateway in a different cluster than the equipment is rejected (cluster mismatch).</summary>
|
||||
[Fact]
|
||||
public async Task CreateAliasTag_rejects_cross_cluster_gateway()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
SeedSecondClusterGateway(dbName);
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.CreateAliasTagAsync(
|
||||
EquipmentId, Input(driverInstanceId: SecondGatewayDriverId));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("cluster");
|
||||
}
|
||||
|
||||
/// <summary>A name already used by another tag on the equipment is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task CreateAliasTag_rejects_duplicate_name()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EXISTING",
|
||||
DriverInstanceId = ModbusDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "speed-alias",
|
||||
DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.CreateAliasTagAsync(EquipmentId, Input(name: "speed-alias"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("speed-alias");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UnsTreeService.LoadAliasTagAsync"/> returns a DTO whose <c>FullName</c> matches the
|
||||
/// TagConfig and whose <c>RowVersion</c> is non-empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadAliasTag_returns_dto_with_FullName()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-LOAD-ALIAS",
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "loaded-speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"FullName\":\"TestMachine_020.LoadedSpeed\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var dto = await service.LoadAliasTagAsync("TAG-LOAD-ALIAS");
|
||||
|
||||
dto.ShouldNotBeNull();
|
||||
dto!.TagId.ShouldBe("TAG-LOAD-ALIAS");
|
||||
dto.Name.ShouldBe("loaded-speed");
|
||||
dto.DriverInstanceId.ShouldBe(GatewayDriverId);
|
||||
dto.DataType.ShouldBe("Float");
|
||||
dto.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
|
||||
dto.FullName.ShouldBe("TestMachine_020.LoadedSpeed");
|
||||
dto.RowVersion.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update changes the alias's name, data type, access level, and Galaxy reference, returning Ok and
|
||||
/// persisting the new values and the refreshed <c>{"FullName":…}</c> TagConfig.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateAliasTag_changes_fields_and_reference()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
byte[] rowVersion;
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-UPD-ALIAS",
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
EquipmentId = EquipmentId,
|
||||
Name = "old-name",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
rowVersion = db.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS").RowVersion;
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var result = await service.UpdateAliasTagAsync(
|
||||
"TAG-UPD-ALIAS",
|
||||
Input(tagId: "TAG-UPD-ALIAS", name: "new-name", dataType: "Double",
|
||||
accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Setpoint"),
|
||||
rowVersion);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var tag = verify.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS");
|
||||
tag.Name.ShouldBe("new-name");
|
||||
tag.DataType.ShouldBe("Double");
|
||||
tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
|
||||
tag.EquipmentId.ShouldBe(EquipmentId);
|
||||
tag.FolderPath.ShouldBeNull();
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
|
||||
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Setpoint");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Small Shouldly-style helper for "exactly one match" assertions used by these tests.</summary>
|
||||
internal static class SingleItemAssertions
|
||||
{
|
||||
public static T ShouldHaveSingleItem<T>(this IEnumerable<T> source, Func<T, bool> predicate)
|
||||
{
|
||||
var matches = source.Where(predicate).ToList();
|
||||
matches.Count.ShouldBe(1);
|
||||
return matches[0];
|
||||
}
|
||||
}
|
||||
-614
@@ -1,614 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="UnsTreeService.ConvertRelayVirtualTagsToAliasesAsync"/>: the converter that
|
||||
/// rewrites pure-relay VirtualTags (body == <c>return ctx.GetTag("X").Value;</c>) into equivalent
|
||||
/// Galaxy alias <see cref="Tag"/> rows, deletes the relay VirtualTag, and prunes the orphaned
|
||||
/// <see cref="Script"/> when nothing else references it. Covers conversion, the skip reasons
|
||||
/// (non-relay, historized, no gateway, unresolvable <c>{{equip}}</c>), dry-run, scope, shared-script
|
||||
/// retention/pruning, and the <c>{{equip}}</c> expansion path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceRelayConverterTests
|
||||
{
|
||||
private const string ClusterId = "MAIN";
|
||||
private const string GatewayDriverId = "DRV-GALAXY";
|
||||
private const string ModbusDriverId = "DRV-MODBUS";
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a cluster with a SystemPlatform namespace + GalaxyMxGateway driver (optional), an
|
||||
/// Equipment-kind namespace + Modbus driver, an area→line, and returns the InMemory db name.
|
||||
/// Equipment rows are added by the caller via <see cref="AddEquipment"/>.
|
||||
/// </summary>
|
||||
private static string SeedCluster(bool withGalaxyGateway = true)
|
||||
{
|
||||
var dbName = $"uns-relay-{Guid.NewGuid():N}";
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
Name = "Main",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-SP",
|
||||
ClusterId = ClusterId,
|
||||
Kind = NamespaceKind.SystemPlatform,
|
||||
NamespaceUri = "urn:zb:sp",
|
||||
});
|
||||
if (withGalaxyGateway)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = "NS-SP",
|
||||
Name = "galaxy gateway",
|
||||
DriverType = "GalaxyMxGateway",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-EQ",
|
||||
ClusterId = ClusterId,
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:eq",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = ModbusDriverId,
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = "NS-EQ",
|
||||
Name = "modbus driver",
|
||||
DriverType = "Modbus",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
|
||||
db.SaveChanges();
|
||||
return dbName;
|
||||
}
|
||||
|
||||
/// <summary>Adds an equipment under LINE-1 in the seeded cluster.</summary>
|
||||
private static void AddEquipment(string dbName, string equipmentId, string machineCode)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-1",
|
||||
Name = equipmentId,
|
||||
MachineCode = machineCode,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Adds a script row with the given source.</summary>
|
||||
private static void AddScript(string dbName, string scriptId, string source)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Scripts.Add(new Script
|
||||
{
|
||||
ScriptId = scriptId,
|
||||
Name = scriptId,
|
||||
SourceCode = source,
|
||||
SourceHash = $"hash-{scriptId}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Adds a virtual tag bound to a script on an equipment.</summary>
|
||||
private static void AddVirtualTag(
|
||||
string dbName, string virtualTagId, string equipmentId, string name, string scriptId,
|
||||
string dataType = "Int32", bool historize = false, bool enabled = true)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Add(new VirtualTag
|
||||
{
|
||||
VirtualTagId = virtualTagId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
ScriptId = scriptId,
|
||||
Historize = historize,
|
||||
Enabled = enabled,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Adds a plain (non-alias) Tag on an equipment, bound to the Modbus driver.</summary>
|
||||
private static void AddPlainTag(string dbName, string tagId, string equipmentId, string name)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = tagId,
|
||||
DriverInstanceId = ModbusDriverId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>Adds an existing Galaxy alias Tag on an equipment (used for {{equip}} base derivation).</summary>
|
||||
private static void AddAliasTag(string dbName, string tagId, string equipmentId, string name, string fullName)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = tagId,
|
||||
DriverInstanceId = GatewayDriverId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = $"{{\"FullName\":\"{fullName}\"}}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static string Relay(string reference) => $"return ctx.GetTag(\"{reference}\").Value;";
|
||||
|
||||
// ----- Case 1: exact relay converted (apply) -----
|
||||
|
||||
/// <summary>
|
||||
/// A pure-relay virtual tag is replaced by a Galaxy alias Tag (gateway-bound, FolderPath null,
|
||||
/// AccessLevel Read, FullName + DataType carried over). The relay VirtualTag and its now-orphan
|
||||
/// Script are gone. The result reports the item and Applied == true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Exact_relay_is_converted_to_alias_tag()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1", dataType: "Int32");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Applied.ShouldBeTrue();
|
||||
var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm");
|
||||
item.EquipmentId.ShouldBe("EQ-1");
|
||||
item.FullName.ShouldBe("TestMachine_020.TestChangingInt");
|
||||
item.DataType.ShouldBe("Int32");
|
||||
result.Skipped.ShouldBeEmpty();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var tag = db.Tags.Single(t => t.Name == "speed-rpm");
|
||||
tag.DriverInstanceId.ShouldBe(GatewayDriverId);
|
||||
tag.EquipmentId.ShouldBe("EQ-1");
|
||||
tag.FolderPath.ShouldBeNull();
|
||||
tag.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
||||
tag.DataType.ShouldBe("Int32");
|
||||
tag.WriteIdempotent.ShouldBeFalse();
|
||||
tag.PollGroupId.ShouldBeNull();
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
|
||||
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.TestChangingInt");
|
||||
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeFalse();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 2: non-relay untouched -----
|
||||
|
||||
/// <summary>A computed body (not a pure relay) is skipped; the VirtualTag and Script remain.</summary>
|
||||
[Fact]
|
||||
public async Task Non_relay_is_skipped_and_unchanged()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", "return ctx.GetTag(\"A.B\").Value * 2;");
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "doubled", "SCRIPT-1");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldBeEmpty();
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "doubled");
|
||||
skip.Reason.ShouldContain("relay");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "doubled").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 3a: two relays share a script -> deleted after both convert -----
|
||||
|
||||
/// <summary>
|
||||
/// One script backs two relay virtual tags. A fleet-wide apply converts both, so the now-orphan
|
||||
/// script is deleted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shared_script_deleted_when_all_referencing_relays_converted()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddEquipment(dbName, "EQ-2", "m_002");
|
||||
AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared"));
|
||||
AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED");
|
||||
AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false);
|
||||
|
||||
result.Converted.Count.ShouldBe(2);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any().ShouldBeFalse();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse();
|
||||
db.Tags.Count(t => t.Name == "a" || t.Name == "b").ShouldBe(2);
|
||||
}
|
||||
|
||||
// ----- Case 3b: scoped convert keeps the script while another relay still needs it -----
|
||||
|
||||
/// <summary>
|
||||
/// One script backs two relay virtual tags on different equipments. A scoped apply (EQ-1 only)
|
||||
/// converts only the first; the script remains because the second relay still references it. A
|
||||
/// follow-up apply for EQ-2 then deletes the script.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shared_script_kept_then_deleted_across_scoped_passes()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddEquipment(dbName, "EQ-2", "m_002");
|
||||
AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared"));
|
||||
AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED");
|
||||
AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var first = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
first.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "a");
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue();
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-B").ShouldBeTrue();
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-A").ShouldBeFalse();
|
||||
}
|
||||
|
||||
var second = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-2", dryRun: false);
|
||||
second.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "b");
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Case 3c: relay shares its script with a NON-relay vtag (scoped) -> script kept -----
|
||||
|
||||
/// <summary>
|
||||
/// A relay shares its (pure-relay) script with another virtual tag on a different equipment. A
|
||||
/// scoped apply converts only the relay's equipment; the script is kept because the other virtual
|
||||
/// tag — left outside scope, so it is not a conversion candidate this pass — still references it.
|
||||
/// This is the "still used by a not-removed vtag" branch of the orphan check.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Script_kept_when_other_referencing_vtag_is_out_of_scope()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddEquipment(dbName, "EQ-2", "m_002");
|
||||
AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared"));
|
||||
AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-SHARED");
|
||||
AddVirtualTag(dbName, "VT-OTHER", "EQ-2", "other", "SCRIPT-SHARED");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed");
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue();
|
||||
verify.VirtualTags.Any(v => v.VirtualTagId == "VT-OTHER").ShouldBeTrue();
|
||||
verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 3d: relay shares its script with a ScriptedAlarm predicate -> script kept -----
|
||||
|
||||
/// <summary>
|
||||
/// A relay's script is also a ScriptedAlarm predicate. Converting the relay must leave the script
|
||||
/// in place because the alarm still references it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Script_kept_when_referenced_by_scripted_alarm()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-PRED", Relay("TestMachine_020.Pred"));
|
||||
AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-PRED");
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.ScriptedAlarms.Add(new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "AL-1",
|
||||
EquipmentId = "EQ-1",
|
||||
Name = "alarm",
|
||||
AlarmType = "AlarmCondition",
|
||||
MessageTemplate = "x",
|
||||
PredicateScriptId = "SCRIPT-PRED",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed");
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Scripts.Any(s => s.ScriptId == "SCRIPT-PRED").ShouldBeTrue();
|
||||
verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 4: Historize=true skipped -----
|
||||
|
||||
/// <summary>A historized relay virtual tag is skipped (Tag has no historize column) and unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task Historized_relay_is_skipped()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.Hist"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "histtag", "SCRIPT-1", historize: true);
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldBeEmpty();
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "histtag");
|
||||
skip.Reason.ShouldContain("historiz", Case.Insensitive);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "histtag").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 5: no gateway -----
|
||||
|
||||
/// <summary>A relay on an equipment whose cluster has no Galaxy gateway is skipped, unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task Relay_skipped_when_no_galaxy_gateway()
|
||||
{
|
||||
var dbName = SeedCluster(withGalaxyGateway: false);
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.X"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "nogw", "SCRIPT-1");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldBeEmpty();
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "nogw");
|
||||
skip.Reason.ShouldContain("no Galaxy gateway");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "nogw").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 6: dry-run no mutation -----
|
||||
|
||||
/// <summary>A dry-run lists the convertible relay but mutates nothing; Applied == false.</summary>
|
||||
[Fact]
|
||||
public async Task Dry_run_lists_but_does_not_mutate()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: true);
|
||||
|
||||
result.Applied.ShouldBeFalse();
|
||||
result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "speed-rpm").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 7: scope -----
|
||||
|
||||
/// <summary>A fleet-wide (null) scope sweeps every equipment's relays.</summary>
|
||||
[Fact]
|
||||
public async Task Fleet_wide_scope_converts_all_equipments()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddEquipment(dbName, "EQ-2", "m_002");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A"));
|
||||
AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1");
|
||||
AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false);
|
||||
|
||||
result.Converted.Count.ShouldBe(2);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.EquipmentId == "EQ-2" && t.Name == "b").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A scoped (single-equipment) call converts only that equipment's relay.</summary>
|
||||
[Fact]
|
||||
public async Task Scoped_call_converts_only_target_equipment()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddEquipment(dbName, "EQ-2", "m_002");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A"));
|
||||
AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1");
|
||||
AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldHaveSingleItem(i => i.EquipmentId == "EQ-1");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue();
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-2").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.EquipmentId == "EQ-2").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Case 8: {{equip}} expansion -----
|
||||
|
||||
/// <summary>
|
||||
/// A relay using the <c>{{equip}}</c> token on an equipment with an existing alias Tag (so a base
|
||||
/// is derivable) is converted with the token expanded against that base.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Equip_token_relay_expands_against_derived_base()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddAliasTag(dbName, "TAG-EXISTING", "EQ-1", "other", "TestMachine_020.Other");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed");
|
||||
item.FullName.ShouldBe("TestMachine_020.Speed");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var tag = db.Tags.Single(t => t.Name == "speed");
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
|
||||
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A relay using the <c>{{equip}}</c> token on an equipment with no derivable base (no existing
|
||||
/// alias tags) is skipped with a reason mentioning the equipment-relative base.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Equip_token_relay_skipped_when_no_derivable_base()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldBeEmpty();
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "speed");
|
||||
skip.Reason.ShouldContain("base", Case.Insensitive);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "speed").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Fix A: existing Tag name collision -> skip + batch continues -----
|
||||
|
||||
/// <summary>
|
||||
/// When a relay VirtualTag has the same name as an existing Tag on the same equipment the
|
||||
/// converter must skip it (not throw DbUpdateException from a unique-index violation) and continue
|
||||
/// converting any other relay on the same equipment in the same batch. The colliding VirtualTag
|
||||
/// and its Script must remain unchanged; exactly one Tag with that name must exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Name_collision_with_existing_tag_is_skipped_and_batch_continues()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
|
||||
// Existing plain Tag named "speed-rpm" (e.g. a manually-created Modbus tag).
|
||||
AddPlainTag(dbName, "TAG-EXISTING", "EQ-1", "speed-rpm");
|
||||
|
||||
// Relay VirtualTag also named "speed-rpm" — would collide on the unique index.
|
||||
AddScript(dbName, "SCRIPT-COLLIDE", Relay("TestMachine_020.SpeedRpm"));
|
||||
AddVirtualTag(dbName, "VT-COLLIDE", "EQ-1", "speed-rpm", "SCRIPT-COLLIDE");
|
||||
|
||||
// A second, non-colliding relay on the same equipment in the same batch.
|
||||
AddScript(dbName, "SCRIPT-OK", Relay("TestMachine_020.Torque"));
|
||||
AddVirtualTag(dbName, "VT-OK", "EQ-1", "torque", "SCRIPT-OK");
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
// The colliding vtag must appear in Skipped with a reason mentioning "already exists".
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "speed-rpm");
|
||||
skip.Reason.ShouldContain("already exists", Case.Insensitive);
|
||||
|
||||
// The non-colliding relay must still have been converted (batch was NOT aborted).
|
||||
result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "torque");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
|
||||
// Exactly one Tag named "speed-rpm" (the original plain tag) — no duplicate was added.
|
||||
db.Tags.Count(t => t.EquipmentId == "EQ-1" && t.Name == "speed-rpm").ShouldBe(1);
|
||||
db.Tags.Single(t => t.EquipmentId == "EQ-1" && t.Name == "speed-rpm").TagId.ShouldBe("TAG-EXISTING");
|
||||
|
||||
// The colliding VirtualTag (and its script) must still be present.
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-COLLIDE").ShouldBeTrue();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-COLLIDE").ShouldBeTrue();
|
||||
|
||||
// The non-colliding relay's Tag exists and its vtag + script are gone.
|
||||
db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "torque").ShouldBeTrue();
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-OK").ShouldBeFalse();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-OK").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Fix B: disabled relay -> skip + report -----
|
||||
|
||||
/// <summary>
|
||||
/// A relay VirtualTag with <c>Enabled == false</c> must be skipped rather than silently promoted
|
||||
/// into an always-active alias Tag. The reason must mention "disabled". The VirtualTag and Script
|
||||
/// remain unchanged and no Tag is added.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disabled_relay_is_skipped()
|
||||
{
|
||||
var dbName = SeedCluster();
|
||||
AddEquipment(dbName, "EQ-1", "m_001");
|
||||
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.DisabledVal"));
|
||||
AddVirtualTag(dbName, "VT-1", "EQ-1", "disabled-relay", "SCRIPT-1", enabled: false);
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
|
||||
|
||||
result.Converted.ShouldBeEmpty();
|
||||
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "disabled-relay");
|
||||
skip.Reason.ShouldContain("disabled", Case.Insensitive);
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue();
|
||||
db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue();
|
||||
db.Tags.Any(t => t.Name == "disabled-relay").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user