docs(scripting): implementation plan for equipment-relative tag paths ({{equip}})
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
# Equipment-Relative Tag Paths (`{{equip}}`) — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Let one virtual-tag script be reused across every equipment instance by
|
||||
substituting a reserved `{{equip}}` token (inside `ctx.GetTag(...)` /
|
||||
`ctx.SetVirtualTag(...)` path literals) with the owning equipment's tag base prefix
|
||||
at deploy time.
|
||||
|
||||
**Architecture:** A pure `Commons` helper derives each equipment's base from the
|
||||
common prefix-before-first-dot of its child-tag `FullName`s and substitutes
|
||||
`{{equip}}` into the script text at the **two compose seams**
|
||||
(`Phase7Composer.Compose` + `DeploymentArtifact.BuildEquipmentVirtualTagPlans`),
|
||||
*before* dependency extraction. After substitution the path is a concrete literal, so
|
||||
the runtime, dependency graph, and editor are untouched. **No schema migration, no
|
||||
new column.** Design: `docs/plans/2026-06-10-equipment-relative-tag-paths-design.md`.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, regex (no Roslyn in the helper), xUnit + Shouldly,
|
||||
in-memory EF for AdminUI tests. No bUnit.
|
||||
|
||||
**Hard rules (carried from prior work):** stage by path (never `git add .`); never
|
||||
stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the
|
||||
gateway API key into a new tracked file; no `--no-verify`; no force-push; **no
|
||||
Configuration entity / migration change**; the agent does **not** sign in to the
|
||||
AdminUI (the user drives `/run`).
|
||||
|
||||
**Branch:** `feat/equip-relative-tag-paths` off `master` (HEAD `50446643`).
|
||||
|
||||
---
|
||||
|
||||
## Verified facts the executor must not re-derive
|
||||
|
||||
- Runtime resolves `ctx.GetTag("X")` by the driver **`FullName`** (the
|
||||
`DependencyMuxActor._byRef` key). The UNS-path engine is dormant. So `{{equip}}`
|
||||
resolves in FullName space; base = substring before the first `.`.
|
||||
- **Both** compose seams already build an `equipmentTags` list (Equipment-kind tags
|
||||
with `EquipmentId` + `FullName`) **before** the virtual-tag plans, and **both**
|
||||
currently carry a duplicated private `ExtractDependencyRefs` + `GetTagRefRegex`.
|
||||
This plan consolidates those into the shared helper.
|
||||
- `OpcUaServer` and `Runtime` both reference `Commons`. `OpcUaServer` does **not**
|
||||
reference `Core.Scripting` (keep the helper Roslyn-free, in `Commons`).
|
||||
- `IScriptTagCatalog.GetTagInfoAsync` returns null for a `{{equip}}` literal (not a
|
||||
configured path) — today that yields a "not a known tag" hover, which task 5 fixes.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Feature branch + `Commons.Tests` project scaffold
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ZB.MOM.WW.OtOpcUa.Commons.Tests.csproj`
|
||||
- Create: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ScaffoldSmokeTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
**Step 1: Branch.**
|
||||
```bash
|
||||
git checkout -b feat/equip-relative-tag-paths
|
||||
```
|
||||
|
||||
**Step 2: Create the test project.** Model the `.csproj` on an existing Core test
|
||||
project that references `Commons`, e.g.
|
||||
`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj`
|
||||
(copy its `TargetFramework`, xUnit + Shouldly + `Microsoft.NET.Test.Sdk` package
|
||||
refs, and `IsPackable=false`). The only project reference needed:
|
||||
```xml
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
|
||||
```
|
||||
Add a trivial passing test in `ScaffoldSmokeTests.cs`:
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
public class ScaffoldSmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Project_builds() => true.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Register in the solution.**
|
||||
```bash
|
||||
dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ZB.MOM.WW.OtOpcUa.Commons.Tests.csproj
|
||||
```
|
||||
If `dotnet sln … add` does not handle `.slnx`, edit `ZB.MOM.WW.OtOpcUa.slnx` by hand
|
||||
to add the `<Project Path="tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/…csproj" />`
|
||||
entry alongside the other test projects.
|
||||
|
||||
**Step 4: Verify.**
|
||||
```bash
|
||||
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests
|
||||
```
|
||||
Expected: 1 passing.
|
||||
|
||||
**Step 5: Commit.**
|
||||
```bash
|
||||
git add tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests ZB.MOM.WW.OtOpcUa.slnx
|
||||
git commit -m "test(commons): scaffold Commons.Tests project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `EquipmentScriptPaths` helper (Commons) + unit tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocks 2–5)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs`
|
||||
- Create: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs`
|
||||
|
||||
**Step 1: Write the failing tests** (TDD — write these first, run, watch them fail to
|
||||
compile/assert). Cover:
|
||||
- `DeriveEquipmentBase`: `["TestMachine_001.A","TestMachine_001.B"]` → `"TestMachine_001"`;
|
||||
divergent `["TestMachine_001.A","DelmiaReceiver_001.B"]` → `null`; empty → `null`;
|
||||
no-dot `["NoDot"]` → `"NoDot"`; null/empty entries skipped.
|
||||
- `SubstituteEquipmentToken`: `ctx.GetTag("{{equip}}.Source")` + `"TestMachine_001"`
|
||||
→ `ctx.GetTag("TestMachine_001.Source")`; `ctx.SetVirtualTag("{{equip}}.Out", x)`
|
||||
substituted; a `{{equip}}` inside a `// comment` or `ctx.Logger.Information("{{equip}}")`
|
||||
string is **left unchanged**; multiple occurrences across literals all substituted;
|
||||
identity when base is null/empty; identity when token absent; a raw-string literal
|
||||
`"""{{equip}}.X"""` is **not** substituted (documents the regex limitation, matches
|
||||
the existing seam extractors).
|
||||
- `ExtractDependencyRefs`: distinct, first-seen order; `ctx.SetVirtualTag("Y", …)` is
|
||||
**not** returned (writes are not dependencies).
|
||||
- `ContainsEquipToken`: true for a `{{equip}}` source, false otherwise / for null.
|
||||
|
||||
**Step 2: Implement** (`EquipmentScriptPaths.cs`):
|
||||
```csharp
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
|
||||
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
||||
/// replaced at the compose seams with the owning equipment's tag base prefix (derived
|
||||
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer
|
||||
/// composer and the Runtime artifact-decode path can both share it. Also the single home
|
||||
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
|
||||
/// duplicate.
|
||||
/// </summary>
|
||||
public static class EquipmentScriptPaths
|
||||
{
|
||||
/// <summary>The reserved equipment-base token.</summary>
|
||||
public const string EquipToken = "{{equip}}";
|
||||
|
||||
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
|
||||
private static readonly Regex GetTagRefRegex =
|
||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||
|
||||
// ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in
|
||||
// three parts (prefix, content, closing quote) so token substitution touches ONLY the
|
||||
// literal content (never a comment, Logger string, or other code).
|
||||
private static readonly Regex PathLiteralRegex =
|
||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>True when the source uses the <c>{{equip}}</c> token anywhere.</summary>
|
||||
/// <param name="source">The script source to scan.</param>
|
||||
public static bool ContainsEquipToken(string? source) =>
|
||||
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Equipment tag base = the single shared substring-before-first-dot across the
|
||||
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
|
||||
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
|
||||
/// </summary>
|
||||
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
|
||||
/// <returns>The shared base prefix, or null when none/ambiguous.</returns>
|
||||
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames)
|
||||
{
|
||||
string? found = null;
|
||||
foreach (var fn in childFullNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fn)) continue;
|
||||
var dot = fn.IndexOf('.');
|
||||
var prefix = dot < 0 ? fn : fn.Substring(0, dot);
|
||||
if (prefix.Length == 0) continue;
|
||||
if (found is null) found = prefix;
|
||||
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
|
||||
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
|
||||
/// script — none of which use the token — is byte-unchanged).
|
||||
/// </summary>
|
||||
/// <param name="source">The script source.</param>
|
||||
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
|
||||
/// <returns>The source with the token substituted inside path literals.</returns>
|
||||
public static string SubstituteEquipmentToken(string source, string? equipBase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source;
|
||||
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source;
|
||||
return PathLiteralRegex.Replace(source, m =>
|
||||
m.Groups[1].Value
|
||||
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal)
|
||||
+ m.Groups[3].Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct <c>ctx.GetTag("ref")</c> string literals in first-seen order — the
|
||||
/// dependency refs the <c>VirtualTagActor</c> subscribes to. The single shared copy
|
||||
/// formerly duplicated in <c>Phase7Composer</c> + <c>DeploymentArtifact</c>. GetTag
|
||||
/// only (writes are not dependencies).
|
||||
/// </summary>
|
||||
/// <param name="scriptSource">The (already substituted) script source.</param>
|
||||
/// <returns>Distinct read refs, first-seen order.</returns>
|
||||
public static IReadOnlyList<string> ExtractDependencyRefs(string? scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
foreach (Match m in GetTagRefRegex.Matches(scriptSource))
|
||||
{
|
||||
var r = m.Groups[1].Value;
|
||||
if (seen.Add(r)) result.Add(r);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run tests green.**
|
||||
```bash
|
||||
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests
|
||||
```
|
||||
|
||||
**Step 4: Commit.**
|
||||
```bash
|
||||
git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs \
|
||||
tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
|
||||
git commit -m "feat(commons): EquipmentScriptPaths — derive base + {{equip}} substitution + shared dep extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire `Phase7Composer` to substitute `{{equip}}`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 3, Task 4, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` (vtag loop ~291–310; delete local `ExtractDependencyRefs` ~349–364 + `GetTagRefRegex` ~346–347)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs` (new)
|
||||
|
||||
**Step 1: Write the failing test.** Build two `Equipment` (`TestMachine_001`,
|
||||
`TestMachine_002`), each with one Equipment-kind `Tag` whose `TagConfig` is
|
||||
`{"FullName":"TestMachine_00N.Source","DataType":"Int32"}`, plus one shared `Script`
|
||||
whose `SourceCode` is
|
||||
`return System.Convert.ToInt32(ctx.GetTag("{{equip}}.Source").Value) > 50;`, and two
|
||||
`VirtualTag`s (one per equipment) referencing that `ScriptId`. Call
|
||||
`Phase7Composer.Compose(... , virtualTags, scripts)` and assert each
|
||||
`EquipmentVirtualTagPlan`:
|
||||
- `.Expression` contains `ctx.GetTag("TestMachine_001.Source")` /
|
||||
`ctx.GetTag("TestMachine_002.Source")` respectively (no `{{equip}}` left).
|
||||
- `.DependencyRefs` equals `["TestMachine_001.Source"]` / `["TestMachine_002.Source"]`.
|
||||
Model setup on the existing `Phase7ComposerPurityTests.cs` for entity construction +
|
||||
the namespace/driver wiring needed for `equipmentTags` to populate (Equipment-kind
|
||||
namespace).
|
||||
|
||||
**Step 2: Implement.** Add `using ZB.MOM.WW.OtOpcUa.Commons.Types;` if absent. After
|
||||
`equipmentTags` is built (~line 289), derive the base map; in the vtag `Select`,
|
||||
substitute before extracting refs:
|
||||
```csharp
|
||||
var baseByEquip = equipmentTags
|
||||
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// … inside the existing vtags.Select(v => { … }):
|
||||
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
src, baseByEquip.GetValueOrDefault(v.EquipmentId));
|
||||
return new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: v.VirtualTagId,
|
||||
EquipmentId: v.EquipmentId,
|
||||
FolderPath: string.Empty,
|
||||
Name: v.Name,
|
||||
DataType: v.DataType,
|
||||
Expression: expanded,
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded));
|
||||
```
|
||||
Delete the now-unused private `ExtractDependencyRefs` method **and** the
|
||||
`GetTagRefRegex` field (the Commons helper replaces them).
|
||||
|
||||
**Step 3: Build + test.**
|
||||
```bash
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests
|
||||
```
|
||||
Expected: new test passes; existing `Phase7Composer*`/`Phase7Applier*` tests still
|
||||
green (purity test must still pass — substitution is deterministic).
|
||||
|
||||
**Step 4: Commit.**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs
|
||||
git commit -m "feat(opcuaserver): Phase7Composer substitutes {{equip}} per equipment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire `DeploymentArtifact` to substitute `{{equip}}` (parity)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 4, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
|
||||
(`ParseComposition` ~196; `BuildEquipmentVirtualTagPlans` ~533–585; delete local
|
||||
`ExtractDependencyRefs` ~596–607 + `GetTagRefRegex` ~587–588)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs` (new)
|
||||
|
||||
**Step 1: Write the failing test.** Build an artifact JSON (model on the existing
|
||||
`DeploymentArtifactTests.cs` construction) containing: a `Tags` array with an
|
||||
Equipment-kind tag `{"EquipmentId":"TestMachine_001","DriverInstanceId":…,"Name":"Source",
|
||||
"TagConfig":"{\"FullName\":\"TestMachine_001.Source\"}",…}` (plus the driver/namespace
|
||||
entries `BuildEquipmentTagPlans` requires), a `Scripts` array with a `{{equip}}` script,
|
||||
and a `VirtualTags` array binding it under `TestMachine_001`. Call
|
||||
`DeploymentArtifact.ParseComposition(blob)` and assert the single
|
||||
`EquipmentVirtualTags[0].Expression` contains `ctx.GetTag("TestMachine_001.Source")`
|
||||
and `.DependencyRefs == ["TestMachine_001.Source"]`.
|
||||
|
||||
**Step 2: Implement.** Add `using ZB.MOM.WW.OtOpcUa.Commons.Types;`. Thread the already-
|
||||
built `equipmentTags` into the vtag-plan builder:
|
||||
- At `ParseComposition` ~line 196: `var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);`
|
||||
- Change the signature to
|
||||
`BuildEquipmentVirtualTagPlans(JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags)`.
|
||||
- At the top of that method, build the base map and substitute per vtag:
|
||||
```csharp
|
||||
var baseByEquip = equipmentTags
|
||||
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
||||
StringComparer.Ordinal);
|
||||
// … per vtag:
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, baseByEquip.GetValueOrDefault(equipmentId!));
|
||||
result.Add(new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: virtualTagId!,
|
||||
EquipmentId: equipmentId!,
|
||||
FolderPath: string.Empty,
|
||||
Name: name!,
|
||||
DataType: dataType ?? "BaseDataType",
|
||||
Expression: expanded,
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded)));
|
||||
```
|
||||
Delete the local `ExtractDependencyRefs` + `GetTagRefRegex` (Commons replaces them).
|
||||
|
||||
**Step 3: Build + test.**
|
||||
```bash
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests
|
||||
```
|
||||
Expected: new test passes; existing `DeploymentArtifactTests` still green (parity with
|
||||
the composer preserved — both now call the same helper on the same `equipmentTags`).
|
||||
|
||||
**Step 4: Commit.**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
|
||||
git commit -m "feat(runtime): DeploymentArtifact substitutes {{equip}} (parity with composer)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: AdminUI save-time validation for `{{equip}}`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs`
|
||||
(`CreateVirtualTagAsync` ~893–935, `UpdateVirtualTagAsync` ~938–985)
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs` (internal
|
||||
static `Extract(string?)`, mirroring `ScriptTagCatalog.ExtractFullNameFromTagConfig`
|
||||
— a deliberate small copy so this task stays disjoint from Task 5's `ScriptTagCatalog`
|
||||
edits; the codebase already keeps parallel copies of this extractor)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs` (new)
|
||||
|
||||
**Step 1: Write the failing tests** (in-memory EF, model on the AdminUI.Tests pattern
|
||||
used by the Monaco `ScriptSourceServiceTests`):
|
||||
- Equipment with a tag `{"FullName":"TestMachine_001.X"}` + a `{{equip}}` script →
|
||||
`CreateVirtualTagAsync` returns `Ok == true`.
|
||||
- Equipment with **no** tags + a `{{equip}}` script → returns `Ok == false` with the
|
||||
derivable-base error message.
|
||||
- A script **without** `{{equip}}` → succeeds regardless of tags.
|
||||
- Same three for `UpdateVirtualTagAsync`.
|
||||
|
||||
**Step 2: Implement.** Add `TagConfigFullName.Extract` (copy the JSON `FullName`
|
||||
extractor from `ScriptTagCatalog.ExtractFullNameFromTagConfig`). Add a private helper to
|
||||
`UnsTreeService`:
|
||||
```csharp
|
||||
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
|
||||
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
|
||||
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
|
||||
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
|
||||
|
||||
var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
|
||||
.Select(t => t.TagConfig).ToListAsync(ct);
|
||||
var fullNames = configs.Select(TagConfigFullName.Extract);
|
||||
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
|
||||
{
|
||||
return new UnsMutationResult(false,
|
||||
$"Equipment '{equipmentId}' has no single tag base, so {{equip}} can't be " +
|
||||
"resolved. Add at least one driver tag under this equipment (all sharing one " +
|
||||
"object prefix), or remove {{equip}} from the script.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
Call it in `CreateVirtualTagAsync` after the name-dup check (~line 919, before the
|
||||
`db.VirtualTags.Add`), passing `equipmentId` + `input.ScriptId`; and in
|
||||
`UpdateVirtualTagAsync` after the name-dup check (~line 965, before applying), passing
|
||||
`entity.EquipmentId` + `input.ScriptId`. Return the result if non-null. Add the
|
||||
`using ZB.MOM.WW.OtOpcUa.Commons.Types;` import.
|
||||
|
||||
> Note the message contains literal `{{equip}}` — in a C# interpolated string `{{`/`}}`
|
||||
> are escaped braces, so it renders as `{equip}`… use a **non-interpolated** concatenation
|
||||
> or `{{{{equip}}}}` so the operator sees `{{equip}}`. (The snippet above is
|
||||
> non-interpolated except the `$"…{equipmentId}…"` segment — keep the token text in a
|
||||
> plain segment.)
|
||||
|
||||
**Step 3: Build + test.**
|
||||
```bash
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests
|
||||
```
|
||||
|
||||
**Step 4: Commit.**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs
|
||||
git commit -m "feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Editor — `{{equip}}`-aware hover + `{{equip}}.` leaf completion
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs`
|
||||
(`CompleteAsync` tag-path branch ~162–167; `Hover` tag-path branch ~243–252)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs`
|
||||
(+ `ScriptTagCatalog` impl)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs` (new)
|
||||
- Modify: the test fake `IScriptTagCatalog` in AdminUI.Tests to implement the new method
|
||||
(find it under `tests/.../ScriptAnalysis/`)
|
||||
|
||||
No JS change is needed — both branches reuse `detail == "tag path"`, which
|
||||
`monaco-init.js` already routes through the whole-literal `literalRange`.
|
||||
|
||||
**Step 1: Catalog method.** Add to `IScriptTagCatalog`:
|
||||
```csharp
|
||||
/// <summary>Distinct attribute leaf names (the substring after the first dot of configured
|
||||
/// FullNames), optionally prefix-filtered — for {{equip}}. completion.</summary>
|
||||
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
|
||||
```
|
||||
Implement in `ScriptTagCatalog` over the existing `BuildEntriesAsync`: for each entry
|
||||
`Path`, take the substring after the first `.` (skip entries with no dot), distinct
|
||||
(Ordinal), `StartsWith(filter)` when filter set, ordered, `Take(MaxResults)`.
|
||||
|
||||
**Step 2: Completion branch.** In `CompleteAsync`, inside
|
||||
`if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix))`, before the
|
||||
existing `GetPathsAsync` call:
|
||||
```csharp
|
||||
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
|
||||
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
|
||||
{
|
||||
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
|
||||
pathPrefix.Substring(equipDot.Length), CancellationToken.None);
|
||||
return new CompletionsResponse(leaves
|
||||
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
|
||||
.ToList());
|
||||
}
|
||||
```
|
||||
(Add `using ZB.MOM.WW.OtOpcUa.Commons.Types;`.)
|
||||
|
||||
**Step 3: Hover branch.** In `Hover`, inside the existing
|
||||
`if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath) && …)` block,
|
||||
before the `GetTagInfoAsync` call:
|
||||
```csharp
|
||||
static string Code(string s) => s.Replace("`", "\\`");
|
||||
if (tagPath.Contains(EquipmentScriptPaths.EquipToken, StringComparison.Ordinal))
|
||||
{
|
||||
return new HoverResponse(
|
||||
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
|
||||
"`{{equip}}` is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
|
||||
}
|
||||
```
|
||||
(Keep the existing `static string Code` once — don't double-declare; reuse the local.)
|
||||
|
||||
**Step 4: Tests** (`ScriptAnalysisService` ctor takes a fake `IScriptTagCatalog`):
|
||||
- `CompleteAsync` on `return ctx.GetTag("{{equip}}.").Value;` with the caret right after
|
||||
the dot → items are `{{equip}}.<leaf>` for the fake's leaves.
|
||||
- `Hover` over a `{{equip}}.Source` literal → markdown starts with
|
||||
`**Equipment-relative path**` (no "Not a known configured tag path").
|
||||
|
||||
**Step 5: Build + test.**
|
||||
```bash
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests
|
||||
```
|
||||
|
||||
**Step 6: Commit.**
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/<fake-catalog-file>.cs
|
||||
git commit -m "feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ScriptEditor.md` (new "Equipment-relative paths (`{{equip}}`)" section)
|
||||
- Modify: `CLAUDE.md` (one sentence in the Scripting section)
|
||||
|
||||
**Step 1:** In `docs/ScriptEditor.md`, document: write `ctx.GetTag("{{equip}}.Attr")`;
|
||||
`{{equip}}` is replaced at deploy with the equipment's tag base (derived from its tags'
|
||||
FullNames before the first dot); one script can be shared across many machines by
|
||||
pointing many VirtualTags' `ScriptId` at it; the equipment must have ≥1 driver tag with
|
||||
a single shared prefix or the save is rejected; editor hover + `{{equip}}.` completion.
|
||||
|
||||
**Step 2:** In `CLAUDE.md` Scripting section, add one line pointing at the `{{equip}}`
|
||||
feature + `docs/ScriptEditor.md`.
|
||||
|
||||
**Step 3: Commit.**
|
||||
```bash
|
||||
git add docs/ScriptEditor.md CLAUDE.md
|
||||
git commit -m "docs(scripting): document {{equip}} equipment-relative tag paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full build + test, then live `/run` verification
|
||||
|
||||
**Classification:** verification
|
||||
**Estimated implement time:** user-driven
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Step 1: Full suite.**
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx
|
||||
```
|
||||
Expected: clean build, all green (AdminUI.Tests includes the new editor + validation
|
||||
tests; Commons/OpcUaServer/Runtime suites include the new ones).
|
||||
|
||||
**Step 2: Live docker-dev `/run` (user drives — agent does NOT sign in).** Rebuild the
|
||||
central AdminUI image, then the user:
|
||||
- Authors a script using `ctx.GetTag("{{equip}}.<attr>")`; confirms editor hover shows
|
||||
the equipment-relative note and `{{equip}}.` offers leaf completions.
|
||||
- Binds that one script to VirtualTags under two different machines; deploys; confirms
|
||||
each resolves its own machine's tag and produces live values.
|
||||
- Confirms binding the `{{equip}}` script to an equipment with no derivable base is
|
||||
rejected with the clear message.
|
||||
|
||||
**Step 3:** On green, finish via superpowers-extended-cc:finishing-a-development-branch
|
||||
(merge `feat/equip-relative-tag-paths` → `master`).
|
||||
|
||||
---
|
||||
|
||||
## Execution notes
|
||||
|
||||
- **Dependency spine:** T0 → T1 → { T2 ∥ T3 ∥ T4 ∥ T5 } → T6 → T7. The four wire-in
|
||||
tasks touch disjoint files (OpcUaServer / Runtime / AdminUI-Uns / AdminUI-ScriptAnalysis)
|
||||
and may be dispatched concurrently after T1.
|
||||
- **Parity is structural:** T2 and T3 both call `EquipmentScriptPaths.ExtractDependencyRefs`
|
||||
+ `.SubstituteEquipmentToken` on a base derived from their respective (already byte-parity)
|
||||
`equipmentTags`. Don't reintroduce a local regex in either seam.
|
||||
- **No Configuration entity/migration change.** If a task seems to need one, stop — the
|
||||
design is explicitly migration-free (base is derived, not stored).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-10-equipment-relative-tag-paths.md",
|
||||
"branch": "feat/equip-relative-tag-paths",
|
||||
"baseBranch": "master",
|
||||
"baseSha": "50446643",
|
||||
"status": "pending",
|
||||
"tasks": [
|
||||
{"id": 183, "planTask": 0, "subject": "Task 0: Feature branch + Commons.Tests scaffold", "classification": "small", "status": "pending"},
|
||||
{"id": 184, "planTask": 1, "subject": "Task 1: EquipmentScriptPaths helper + tests", "classification": "standard", "status": "pending", "blockedBy": [183]},
|
||||
{"id": 185, "planTask": 2, "subject": "Task 2: Phase7Composer substitutes {{equip}}", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [186, 187, 188]},
|
||||
{"id": 186, "planTask": 3, "subject": "Task 3: DeploymentArtifact substitutes {{equip}} (parity)", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 187, 188]},
|
||||
{"id": 187, "planTask": 4, "subject": "Task 4: AdminUI {{equip}} save validation", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 186, 188]},
|
||||
{"id": 188, "planTask": 5, "subject": "Task 5: Editor {{equip}} hover + leaf completion", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 186, 187]},
|
||||
{"id": 189, "planTask": 6, "subject": "Task 6: Docs ({{equip}})", "classification": "small", "status": "pending", "blockedBy": [185, 186, 187, 188]},
|
||||
{"id": 190, "planTask": 7, "subject": "Task 7: Full build/test + live /run verify", "classification": "verification", "status": "pending", "blockedBy": [185, 186, 187, 188, 189]}
|
||||
],
|
||||
"lastUpdated": "2026-06-10"
|
||||
}
|
||||
Reference in New Issue
Block a user