merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework

This commit is contained in:
Joseph Doherty
2026-07-16 06:59:22 -04:00
23 changed files with 1142 additions and 426 deletions
@@ -8,29 +8,17 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the save-time equipment-relative-path guard on <see cref="UnsTreeService"/>: when a
/// VirtualTag binds a script that uses the reserved <c>{{equip}}</c> token, the owning equipment
/// must have a derivable tag base (≥1 driver tag, all sharing one object prefix). Otherwise the
/// create/update is rejected with an error naming the equipment and the unresolvable token.
/// v3 (M1): verifies the save-time equipment-relative-path guard on <see cref="UnsTreeService"/>. When a
/// VirtualTag binds a script that uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names — otherwise the
/// create/update is rejected with an error naming the equipment and the unresolved token. This mirrors the
/// deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>): editor/authoring accept ⇔ publish accepts.
/// </summary>
/// <remarks>
/// Reuses the shared <see cref="UnsTreeTestDb"/> InMemory fixture pattern: a uniquely-named
/// InMemory database seeded with an area→line→equipment path plus a script, with driver tags
/// added per-test so the base-derivation outcome is what each case isolates.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class VirtualTagEquipTokenValidationTests
{
private const string EquipBaseScript = "return ctx.GetTag(\"{{equip}}.X\");";
private const string PlainScript = "return ctx.GetTag(\"TestMachine_001.X\");";
// v3 Batch-1: the equipment↔Tag binding was retired (Tag is Raw-only), so the {{equip}} token's
// equipment-tag-derived base can never resolve — ValidateEquipTokenAsync now derives from an empty
// tag set, so any {{equip}} script is rejected. The token-present/no-base rejection and the
// no-token success cases stay live below; the tests that assert a DERIVABLE base (success) or the
// divergent-prefix derivation logic are dark until per-equipment tag references return in Batch 3.
private const string DarkUntilBatch3 =
"v3 Batch-1: {{equip}} equipment-tag-derived base is dark — per-equipment tag references return in Batch 3.";
private const string EquipRefScript = "return ctx.GetTag(\"{{equip}}/Speed\").Value;";
private const string PlainScript = "return ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\").Value;";
private static (UnsTreeService Service, string DbName) Fresh()
{
@@ -40,10 +28,11 @@ public sealed class VirtualTagEquipTokenValidationTests
/// <summary>
/// Seeds an area→line→equipment path (equipment id <c>EQ-1</c>) plus one script whose source is
/// <paramref name="scriptSource"/>, and optionally a driver tag whose <c>TagConfig</c> carries the
/// supplied <c>FullName</c> so the equipment has a derivable base.
/// <paramref name="scriptSource"/>, and — when <paramref name="referenceName"/> is set — a raw tag with
/// that Name plus an <c>UnsTagReference</c> from EQ-1 to it (so the equipment has a reference whose
/// effective name is <paramref name="referenceName"/>).
/// </summary>
private static void Seed(string dbName, string scriptSource, string? tagFullName)
private static void Seed(string dbName, string scriptSource, string? referenceName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
@@ -64,17 +53,23 @@ public sealed class VirtualTagEquipTokenValidationTests
SourceHash = "hash-1",
Language = "CSharp",
});
if (tagFullName is not null)
if (referenceName is not null)
{
// v3: raw tag (no equipment binding). Kept so the dark {{equip}}-base tests still compile.
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DeviceId = "DEV-1",
Name = "x",
Name = referenceName,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = $"{{\"FullName\":\"{tagFullName}\"}}",
TagConfig = "{}",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = "REF-1",
EquipmentId = "EQ-1",
TagId = "TAG-1",
DisplayNameOverride = null,
});
}
db.SaveChanges();
@@ -102,12 +97,12 @@ public sealed class VirtualTagEquipTokenValidationTests
// ----- Create -----
/// <summary>{{equip}} script + a driver tag whose FullName gives a base → create succeeds.</summary>
[Fact(Skip = DarkUntilBatch3)]
public async Task Create_equip_token_with_derivable_base_succeeds()
/// <summary>{{equip}}/Speed + a matching reference named "Speed" → create succeeds.</summary>
[Fact]
public async Task Create_equip_ref_that_resolves_succeeds()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
Seed(dbName, EquipRefScript, referenceName: "Speed");
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
@@ -115,30 +110,30 @@ public sealed class VirtualTagEquipTokenValidationTests
result.Error.ShouldBeNull();
}
/// <summary>{{equip}} script + no driver tags → create rejected, error names equipment + token.</summary>
/// <summary>{{equip}}/Speed + no matching reference → create rejected, error names equipment + token.</summary>
[Fact]
public async Task Create_equip_token_without_base_rejected()
public async Task Create_equip_ref_unresolved_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: null);
Seed(dbName, EquipRefScript, referenceName: null);
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}");
result.Error.ShouldContain("{{equip}}/Speed");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
/// <summary>A script with no {{equip}} token → create succeeds regardless of tags.</summary>
/// <summary>A script with no {{equip}} token → create succeeds regardless of references.</summary>
[Fact]
public async Task Create_no_equip_token_succeeds_without_tags()
public async Task Create_no_equip_token_succeeds_without_references()
{
var (service, dbName) = Fresh();
Seed(dbName, PlainScript, tagFullName: null);
Seed(dbName, PlainScript, referenceName: null);
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
@@ -146,48 +141,14 @@ public sealed class VirtualTagEquipTokenValidationTests
result.Error.ShouldBeNull();
}
/// <summary>
/// {{equip}} script + TWO driver tags whose FullNames have DIFFERENT object prefixes
/// (no single base can be derived) → create rejected, error names equipment + token.
/// </summary>
[Fact(Skip = DarkUntilBatch3)]
public async Task Create_equip_token_with_divergent_prefixes_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-2",
DeviceId = "DEV-1",
Name = "y",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.Y\"}",
});
db.SaveChanges();
}
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}");
using var verifyDb = UnsTreeTestDb.CreateNamed(dbName);
verifyDb.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
}
// ----- Update -----
/// <summary>{{equip}} script + a derivable base → update succeeds.</summary>
[Fact(Skip = DarkUntilBatch3)]
public async Task Update_equip_token_with_derivable_base_succeeds()
/// <summary>{{equip}}/Speed + a matching reference → update succeeds.</summary>
[Fact]
public async Task Update_equip_ref_that_resolves_succeeds()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
Seed(dbName, EquipRefScript, referenceName: "Speed");
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
@@ -196,12 +157,12 @@ public sealed class VirtualTagEquipTokenValidationTests
result.Error.ShouldBeNull();
}
/// <summary>{{equip}} script + no driver tags → update rejected, error names equipment + token.</summary>
/// <summary>{{equip}}/Speed + no matching reference → update rejected, error names equipment + token.</summary>
[Fact]
public async Task Update_equip_token_without_base_rejected()
public async Task Update_equip_ref_unresolved_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipBaseScript, tagFullName: null);
Seed(dbName, EquipRefScript, referenceName: null);
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
@@ -209,18 +170,18 @@ public sealed class VirtualTagEquipTokenValidationTests
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}");
result.Error.ShouldContain("{{equip}}/Speed");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").Name.ShouldBe("computed");
}
/// <summary>A script with no {{equip}} token → update succeeds regardless of tags.</summary>
/// <summary>A script with no {{equip}} token → update succeeds regardless of references.</summary>
[Fact]
public async Task Update_no_equip_token_succeeds_without_tags()
public async Task Update_no_equip_token_succeeds_without_references()
{
var (service, dbName) = Fresh();
Seed(dbName, PlainScript, tagFullName: null);
Seed(dbName, PlainScript, referenceName: null);
var rv = SeedVirtualTagAndRowVersion(dbName);
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);