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:
+1
-1
@@ -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>());
|
||||
}
|
||||
|
||||
|
||||
+59
-26
@@ -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}}/<RefName> 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}}/<RefName></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}}/<RefName> 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" });
|
||||
}
|
||||
|
||||
|
||||
+54
-70
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
+43
-82
@@ -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}}/<RefName></c>, every <c><RefName></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);
|
||||
|
||||
Reference in New Issue
Block a user