feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration)

This commit is contained in:
Joseph Doherty
2026-06-13 11:46:00 -04:00
parent f8f1027287
commit a23fb2b82e
15 changed files with 170 additions and 43 deletions
@@ -93,7 +93,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> CallQueue.Enqueue($"EV:{variableNodeId}");
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
@@ -143,7 +143,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
},
};
@@ -264,7 +264,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { }
}
@@ -131,14 +131,15 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
}
@@ -157,14 +158,15 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
// A Read plan threads Writable: false (the node stays CurrentRead).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
}
@@ -183,16 +185,16 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
}
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
@@ -218,7 +220,8 @@ public sealed class Phase7ApplierTests
applier.MaterialiseEquipmentVirtualTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
// VirtualTags are computed outputs — always read-only (Writable: false).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
}
@@ -240,8 +243,8 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
},
EquipmentVirtualTags = new[]
{
@@ -286,8 +289,8 @@ public sealed class Phase7ApplierTests
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
}
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
@@ -335,7 +338,7 @@ public sealed class Phase7ApplierTests
{
AddedEquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
},
};
@@ -394,7 +397,7 @@ public sealed class Phase7ApplierTests
/// <summary>Gets the queue of folder creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the queue of variable creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
@@ -405,7 +408,7 @@ public sealed class Phase7ApplierTests
/// <summary>Gets the list of recorded folder creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Gets the list of recorded variable creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList();
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
@@ -440,8 +443,9 @@ public sealed class Phase7ApplierTests
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
@@ -485,7 +489,8 @@ public sealed class Phase7ApplierTests
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
}
@@ -43,7 +43,7 @@ public sealed class Phase7PlannerTests
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
},
};
@@ -62,6 +62,80 @@ public sealed class DeploymentArtifactAliasParityTests
tag.DataType.ShouldBe("Int32");
tag.FolderPath.ShouldBe(string.Empty);
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
// No AccessLevel in the blob → defaults to non-writable (read-only node).
tag.Writable.ShouldBeFalse();
}
/// <summary>The artifact decoder reads <c>AccessLevel</c> into <c>EquipmentTagPlan.Writable</c>:
/// ReadWrite → true, Read → false. ConfigComposer emits the enum numerically (no string converter),
/// so the numeric form (1 = ReadWrite) is the canonical wire shape, but the decoder also tolerates
/// the string form ("ReadWrite") defensively — mirroring how the Kind gate accepts both.</summary>
[Theory]
[InlineData(1, true)] // numeric ReadWrite
[InlineData(0, false)] // numeric Read
public void ParseComposition_maps_numeric_AccessLevel_to_Writable(int accessLevel, bool expectedWritable)
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-1",
DriverInstanceId = "drv",
EquipmentId = "eq-1",
Name = "Speed",
FolderPath = (string?)null,
DataType = "Float",
AccessLevel = accessLevel,
TagConfig = "{\"FullName\":\"40001\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
}
/// <summary>The decoder also tolerates the string enum form ("ReadWrite"/"Read") in case a future
/// serializer registers a string converter — byte-parity safety, mirroring the Kind gate.</summary>
[Theory]
[InlineData("ReadWrite", true)]
[InlineData("Read", false)]
public void ParseComposition_maps_string_AccessLevel_to_Writable(string accessLevel, bool expectedWritable)
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
DriverInstances = new[]
{
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-1",
DriverInstanceId = "drv",
EquipmentId = "eq-1",
Name = "Speed",
FolderPath = (string?)null,
DataType = "Float",
AccessLevel = accessLevel,
TagConfig = "{\"FullName\":\"40001\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
}
/// <summary>An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in
@@ -239,6 +313,7 @@ public sealed class DeploymentArtifactAliasParityTests
d.Name.ShouldBe(x.Name);
d.DataType.ShouldBe(x.DataType);
d.FullName.ShouldBe(x.FullName);
d.Writable.ShouldBe(x.Writable);
}
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
@@ -246,6 +321,15 @@ public sealed class DeploymentArtifactAliasParityTests
galaxyPlan.EquipmentId.ShouldBe("eq-galaxy");
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
// SequenceEqual above already proves they agree element-wise.
galaxyPlan.Writable.ShouldBeFalse(); // AccessLevel = Read
var modbusPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus");
modbusPlan.Writable.ShouldBeTrue(); // AccessLevel = ReadWrite
composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Writable.ShouldBeFalse();
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
}
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
@@ -258,6 +342,9 @@ public sealed class DeploymentArtifactAliasParityTests
t.Name,
t.FolderPath,
t.DataType,
// ConfigComposer serialises with no JsonStringEnumConverter, so the TagAccessLevel enum lands
// as its numeric value (Read = 0, ReadWrite = 1) — exactly like Kind = (int)ns.Kind above.
AccessLevel = (int)t.AccessLevel,
t.TagConfig,
};
}
@@ -217,7 +217,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ }
}
@@ -287,7 +287,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> Calls.Enqueue($"EV:{variableNodeId}");
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
@@ -208,7 +208,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA built-in type name.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
/// <param name="writable">Whether the node is created read/write.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// <summary>Records a rebuild call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);