feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution

Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 06:58:17 -04:00
parent a88dc86173
commit dc0d7653b9
23 changed files with 1142 additions and 426 deletions
@@ -14,7 +14,7 @@ public sealed class CtxCompletionGuardTests
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" });
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null));
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct)
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? f, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
}
@@ -5,63 +5,96 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the
/// <c>{{equip}}</c> token shows an "equipment-relative" note (not the "not a known configured
/// tag path" warning), and completion after <c>{{equip}}.</c> offers the attribute leaf names.
/// v3 {{equip}}/&lt;RefName&gt; editor niceties: hover on a path literal containing the token shows an
/// "equipment-relative" note (not the "not a known configured tag path" warning); completion after
/// <c>{{equip}}/</c> offers the owning equipment's reference effective names; and the diagnostic flags an
/// unresolved <c>{{equip}}/&lt;RefName&gt;</c> exactly as the deploy gate does (editor accepts ⇔ publish
/// accepts).
/// </summary>
public sealed class EquipTokenEditorTests
{
/// <summary>A fake catalog whose configured paths all share an object prefix, so the attribute
/// leaf names ("Source", "Other") are what {{equip}}. completion should surface.</summary>
/// <summary>A fake catalog whose equipment has two references — "Speed" and "Temp" — so those are the
/// resolvable {{equip}}/&lt;RefName&gt; names for the completion + diagnostic.</summary>
private sealed class FakeCatalog : IScriptTagCatalog
{
private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(Paths);
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Cell1/Modbus/Dev1/Speed" });
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{
IEnumerable<string> leaves = new[] { "Source", "Other" };
IEnumerable<string> names = new[] { "Speed", "Temp" };
if (!string.IsNullOrWhiteSpace(filter))
leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return Task.FromResult<IReadOnlyList<string>>(leaves.ToList());
names = names.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return Task.FromResult<IReadOnlyList<string>>(names.ToList());
}
}
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col)
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col, string? equipmentId)
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col, equipmentId))).Items;
[Fact] public async Task Completion_after_equip_dot_offers_attribute_leaves()
[Fact] public async Task Completion_after_equip_slash_offers_reference_names()
{
// caret right after the dot of "{{equip}}." — column 30 sits between the trailing dot and
// the closing quote of ctx.GetTag("{{equip}}.").
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
items.Select(i => i.Label).ShouldContain("{{equip}}.Source");
// caret right after the slash of "{{equip}}/" — column 30 sits between the trailing slash and the
// closing quote of ctx.GetTag("{{equip}}/").
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
items.Select(i => i.Label).ShouldContain("{{equip}}/Speed");
items.Select(i => i.Label).ShouldContain("{{equip}}/Temp");
items.ShouldAllBe(i => i.Detail == "tag path");
}
[Fact] public async Task Completion_after_equip_dot_inserts_the_full_token_qualified_leaf()
[Fact] public async Task Completion_after_equip_slash_inserts_the_full_token_qualified_ref()
{
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
var source = items.FirstOrDefault(i => i.Label == "{{equip}}.Source");
source.ShouldNotBeNull();
source!.InsertText.ShouldBe("{{equip}}.Source");
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
var speed = items.FirstOrDefault(i => i.Label == "{{equip}}/Speed");
speed.ShouldNotBeNull();
speed!.InsertText.ShouldBe("{{equip}}/Speed");
}
[Fact] public async Task Completion_after_equip_slash_is_inert_without_equipment_context()
{
// Shared ScriptEdit page (no EquipmentId) can't resolve references → no equip completions.
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, null);
items.ShouldBeEmpty();
}
[Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative()
{
// caret inside ctx.GetTag("{{equip}}.Source")
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}.Source").Value;""", 1, 24))).Markdown;
// caret inside ctx.GetTag("{{equip}}/Speed")
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", 1, 24))).Markdown;
md.ShouldNotBeNull();
md!.ShouldContain("Equipment-relative");
// the {{equip}} token must render literally (non-interpolated markdown segment)
md.ShouldContain("{{equip}}");
md.ShouldNotContain("Not a known");
}
[Fact] public async Task Diagnostic_flags_unresolved_equip_reference()
{
// "Missing" is not one of the equipment's references (Speed/Temp) → a diagnostic, matching the
// deploy gate. Uses the equipment context via the request's EquipmentId.
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;""", "EQ-1"));
resp.Markers.ShouldContain(m => m.Code == "OTSCRIPT_EQUIPREF" && m.Message.Contains("Missing"));
}
[Fact] public async Task Diagnostic_clean_for_resolved_equip_reference()
{
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", "EQ-1"));
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
}
[Fact] public async Task Diagnostic_equipref_inert_without_equipment_context()
{
// No EquipmentId (shared ScriptEdit page) → the equip-ref check is inert (base markers only),
// matching the deploy gate which only resolves per owning equipment.
var resp = await Svc.DiagnoseAsync(
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;"""));
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
}
}
@@ -16,7 +16,7 @@ public sealed class HoverSignatureTests
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
}
@@ -285,82 +285,66 @@ public sealed class ScriptTagCatalogTests
}
// -----------------------------------------------------------------------
// GetEquipmentRelativeLeavesAsync — DB-backed tests
// GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
// -----------------------------------------------------------------------
// Seeded paths and their leaf derivation:
// TAG-EQ → "Motor.Speed" → leaf "Speed"
// TAG-SP → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath"
// VTAG-1 → "Computed" → NO dot → excluded
// Effective name = UnsTagReference.DisplayNameOverride else the backing raw tag's Name.
// -----------------------------------------------------------------------
/// <summary>A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded.</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only()
/// <summary>Seeds equipment EQ-1 with two references: one to TAG-EQ (Name "Speed", no override) and one
/// to TAG-SP (Name "DownloadPath") under a display override "Path".</summary>
private static void SeedReferences(DbContextOptions<OtOpcUaConfigDbContext> opts)
{
var (catalog, opts) = Fresh();
Seed(opts);
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
// Leaves derived from the two dot-bearing paths in Seed().
leaves.ShouldContain("Speed"); // from "Motor.Speed"
leaves.ShouldContain("DownloadPath"); // from "DelmiaReceiver_001.DownloadPath"
// Virtual tag "Computed" has no dot — must be excluded.
leaves.ShouldNotContain("Computed");
}
/// <summary>A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath").</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves()
{
var (catalog, opts) = Fresh();
Seed(opts);
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default);
leaves.ShouldContain("Speed");
leaves.ShouldNotContain("DownloadPath");
leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase));
}
/// <summary>The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed".</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive()
{
var (catalog, opts) = Fresh();
Seed(opts);
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default);
leaves.ShouldContain("Speed");
}
/// <summary>Two paths sharing the same leaf after different object prefixes produce one distinct entry.</summary>
[Fact]
public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated()
{
var (catalog, opts) = Fresh();
Seed(opts);
// Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ.
using (var db = new OtOpcUaConfigDbContext(opts))
using var db = new OtOpcUaConfigDbContext(opts);
db.UnsTagReferences.Add(new UnsTagReference
{
db.Tags.Add(new Tag
{
TagId = "TAG-EQ2",
DeviceId = "DEV-1",
Name = "Speed2",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"Conveyor.Speed\"}",
});
db.SaveChanges();
}
UnsTagReferenceId = "REF-1", EquipmentId = "EQ-1", TagId = "TAG-EQ", DisplayNameOverride = null,
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = "REF-2", EquipmentId = "EQ-1", TagId = "TAG-SP", DisplayNameOverride = "Path",
});
db.SaveChanges();
}
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
/// <summary>Returns the equipment's reference effective names — backing tag Name when no override, the
/// override otherwise.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_returns_effective_names()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
// "Speed" must appear exactly once despite being produced by two different paths.
leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1);
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", null, default);
names.ShouldContain("Speed"); // TAG-EQ Name, no override
names.ShouldContain("Path"); // TAG-SP under the "Path" override
names.ShouldNotContain("DownloadPath"); // the raw Name is superseded by the override
}
/// <summary>A prefix filter narrows (case-insensitively) to reference names that start with the prefix.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_prefix_filter_is_case_insensitive()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", "sp", default);
names.ShouldContain("Speed");
names.ShouldNotContain("Path");
}
/// <summary>An unknown equipment (or one with no references) yields an empty set; a blank id yields empty.</summary>
[Fact]
public async Task GetEquipmentReferenceNames_empty_for_unknown_or_blank_equipment()
{
var (catalog, opts) = Fresh();
Seed(opts);
SeedReferences(opts);
(await catalog.GetEquipmentReferenceNamesAsync("EQ-UNKNOWN", null, default)).ShouldBeEmpty();
(await catalog.GetEquipmentReferenceNamesAsync("", null, default)).ShouldBeEmpty();
}
}
@@ -14,8 +14,7 @@ public sealed class TagPathCompletionTests
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
// canned leaves derived from the canned paths above (substring after the first dot)
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" });
}
@@ -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);
@@ -0,0 +1,125 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 WP4 byte-parity: the artifact-decode seam (<c>DeploymentArtifact.ParseComposition</c>) and the
/// live compose seam (<c>AddressSpaceComposer.Compose</c>) must resolve <c>{{equip}}/&lt;RefName&gt;</c>
/// script paths to the SAME backing RawPath — both build the per-equipment reference map (effective name
/// → RawPath) from <c>UnsTagReferences</c> + <c>Tags</c> + the raw topology via the shared
/// <c>EquipmentReferenceMap</c> + <c>RawPathResolver</c>. Exercises both a VirtualTag script and a
/// scripted-alarm predicate, plus an override-named reference.
/// </summary>
public sealed class DeploymentArtifactEquipRefParityTests
{
// Reference effective name "MotorSpeed" (a DisplayNameOverride) backing raw tag leaf "Speed" →
// RawPath "Modbus/Dev1/Speed" (driver at cluster root, no folder / group).
private const string VtScript = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 50;";
private const string AlarmScript = "return System.Convert.ToDouble(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 90;";
private const string ExpectedRawPath = "Modbus/Dev1/Speed";
private static DriverInstance Driver() => new()
{
DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus",
DriverConfig = "{}", RawFolderId = null,
};
private static Device Dev() => new()
{
DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}",
};
private static Tag RawTag() => new()
{
TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
private static UnsTagReference Ref() => new()
{
UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed",
};
[Fact]
public void VirtualTag_equip_ref_resolves_byte_parity()
{
var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = VtScript, SourceHash = "h" };
var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" };
var composed = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
new[] { Driver() }, Array.Empty<ScriptedAlarm>(),
virtualTags: new[] { vt }, scripts: new[] { script },
unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() },
rawFolders: Array.Empty<RawFolder>(), devices: new[] { Dev() }, tagGroups: Array.Empty<TagGroup>());
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } },
Scripts = new[] { new { ScriptId = "s-1", SourceCode = VtScript } },
VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// The token resolved to the backing RawPath on BOTH sides, and the plans are byte-identical.
var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem();
cPlan.Expression.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")");
cPlan.Expression.ShouldNotContain("{{equip}}");
cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath });
var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem();
dPlan.ShouldBe(cPlan);
}
[Fact]
public void ScriptedAlarm_predicate_equip_ref_resolves_byte_parity()
{
var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = AlarmScript, SourceHash = "h" };
var alarm = new ScriptedAlarm
{
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm",
Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1",
HistorizeToAveva = true, Retain = true, Enabled = true,
};
var composed = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
new[] { Driver() }, new[] { alarm },
virtualTags: Array.Empty<VirtualTag>(), scripts: new[] { script },
unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() },
rawFolders: Array.Empty<RawFolder>(), devices: new[] { Dev() }, tagGroups: Array.Empty<TagGroup>());
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } },
Scripts = new[] { new { ScriptId = "s-1", SourceCode = AlarmScript } },
ScriptedAlarms = new[]
{
new { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1", HistorizeToAveva = true, Retain = true, Enabled = true },
},
});
var decoded = DeploymentArtifact.ParseComposition(blob);
var cPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
cPlan.PredicateSource.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")");
cPlan.PredicateSource.ShouldNotContain("{{equip}}");
cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath });
var dPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
dPlan.ShouldBe(cPlan);
}
}