dc0d7653b9
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
126 lines
6.7 KiB
C#
126 lines
6.7 KiB
C#
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}}/<RefName></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);
|
|
}
|
|
}
|