4624141a5d
tag gate, and fix three silently-broken OpcUaClient integration tests
Continues working through deferment.md's remaining items.
SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
- REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
E2E. The behaviour never went dark, only the v2 seeding did.
- FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
already compared RawTagPlan record-equal and RawTagPlan carries array /
historize / alarm intent, so widening its corpus by two tags covers what
three empty placeholder suites had been promising. Those suites are deleted.
- DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
and both dot-joint {{equip}}.X token suites (that resolver was deleted in
Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.
G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.
OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.
DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
643 lines
28 KiB
C#
643 lines
28 KiB
C#
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
public sealed class DeploymentArtifactTests
|
|
{
|
|
private static byte[] BlobOf(object snapshot) =>
|
|
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(snapshot);
|
|
|
|
private static object MultiClusterSnapshot() => new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
|
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "main-galaxy", Name = "g", DriverType = "GalaxyMxGateway", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
|
new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "sa-modbus", Name = "m", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
|
},
|
|
};
|
|
|
|
/// <summary>Verifies a single-cluster artifact resolves to None (apply everything).</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_single_cluster_artifact_returns_None()
|
|
{
|
|
var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = Array.Empty<object>() });
|
|
var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053");
|
|
scope.Mode.ShouldBe(ClusterFilterMode.None);
|
|
}
|
|
|
|
/// <summary>Verifies a multi-cluster artifact scopes a known node to its own ClusterId.</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_multi_cluster_known_node_scopes_to_its_cluster()
|
|
{
|
|
var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "site-a-1:4053");
|
|
scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo);
|
|
scope.ClusterId.ShouldBe("SITE-A");
|
|
}
|
|
|
|
/// <summary>Verifies a multi-cluster artifact suppresses an unknown node.</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_multi_cluster_unknown_node_suppresses()
|
|
{
|
|
var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "ghost-9:4053");
|
|
scope.Mode.ShouldBe(ClusterFilterMode.Suppress);
|
|
}
|
|
|
|
/// <summary>Verifies the scoped parse returns only the node's own cluster's drivers.</summary>
|
|
[Fact]
|
|
public void ParseDriverInstances_scoped_returns_only_my_clusters_drivers()
|
|
{
|
|
var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "central-1:4053");
|
|
specs.Select(s => s.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
|
}
|
|
|
|
/// <summary>Verifies the scoped parse returns nothing for an unknown node.</summary>
|
|
[Fact]
|
|
public void ParseDriverInstances_scoped_unknown_node_returns_empty()
|
|
{
|
|
var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "ghost-9:4053");
|
|
specs.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies the scoped parse returns all drivers for a single-cluster artifact.</summary>
|
|
[Fact]
|
|
public void ParseDriverInstances_scoped_single_cluster_returns_all()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" } },
|
|
Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } },
|
|
DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "d1", Name = "d", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN" } },
|
|
});
|
|
DeploymentArtifact.ParseDriverInstances(blob, "anything:4053").Select(s => s.DriverInstanceId).ShouldBe(new[] { "d1" });
|
|
}
|
|
/// <summary>Verifies that empty blob returns empty list.</summary>
|
|
[Fact]
|
|
public void Empty_blob_returns_empty_list()
|
|
{
|
|
DeploymentArtifact.ParseDriverInstances(ReadOnlySpan<byte>.Empty).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that malformed JSON returns empty list.</summary>
|
|
[Fact]
|
|
public void Malformed_json_returns_empty_list()
|
|
{
|
|
DeploymentArtifact.ParseDriverInstances(Encoding.UTF8.GetBytes("not json")).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that snapshot without DriverInstances returns empty.</summary>
|
|
[Fact]
|
|
public void Snapshot_without_DriverInstances_returns_empty()
|
|
{
|
|
var blob = Encoding.UTF8.GetBytes("{\"Clusters\":[]}");
|
|
DeploymentArtifact.ParseDriverInstances(blob).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that driver instances are parsed from composer-shaped blob.</summary>
|
|
[Fact]
|
|
public void Parses_driver_instances_from_composer_shaped_blob()
|
|
{
|
|
// Mirrors the shape ConfigComposer.SnapshotAndFlattenAsync emits — Pascal-case fields
|
|
// serialised directly off the EF entity.
|
|
var rowId = Guid.NewGuid();
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
DriverInstances = new[]
|
|
{
|
|
new
|
|
{
|
|
DriverInstanceRowId = rowId,
|
|
DriverInstanceId = "DI-modbus-1",
|
|
Name = "Modbus Line A",
|
|
DriverType = "Modbus",
|
|
Enabled = true,
|
|
DriverConfig = "{\"host\":\"127.0.0.1\"}",
|
|
},
|
|
new
|
|
{
|
|
DriverInstanceRowId = Guid.NewGuid(),
|
|
DriverInstanceId = "DI-disabled",
|
|
Name = "Decommissioned",
|
|
DriverType = "AbCip",
|
|
Enabled = false,
|
|
DriverConfig = "{}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
|
|
|
specs.Count.ShouldBe(2);
|
|
specs[0].DriverInstanceRowId.ShouldBe(rowId);
|
|
specs[0].DriverInstanceId.ShouldBe("DI-modbus-1");
|
|
specs[0].DriverType.ShouldBe("Modbus");
|
|
specs[0].Enabled.ShouldBeTrue();
|
|
specs[0].DriverConfig.ShouldContain("127.0.0.1");
|
|
specs[1].Enabled.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Verifies that ParseComposition returns empty for empty blob.</summary>
|
|
[Fact]
|
|
public void ParseComposition_returns_empty_for_empty_blob()
|
|
{
|
|
var c = DeploymentArtifact.ParseComposition(ReadOnlySpan<byte>.Empty);
|
|
c.EquipmentNodes.ShouldBeEmpty();
|
|
c.DriverInstancePlans.ShouldBeEmpty();
|
|
c.ScriptedAlarmPlans.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that ParseComposition reads all three entity classes sorted by ID.</summary>
|
|
[Fact]
|
|
public void ParseComposition_reads_all_three_entity_classes_sorted_by_id()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "eq-z", MachineCode = "Z", UnsLineId = "line-1" },
|
|
new { EquipmentId = "eq-a", MachineCode = "A", UnsLineId = "line-1" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}" },
|
|
},
|
|
ScriptedAlarms = new[]
|
|
{
|
|
new
|
|
{
|
|
ScriptedAlarmId = "alarm-1",
|
|
EquipmentId = "eq-a",
|
|
PredicateScriptId = "script-1",
|
|
MessageTemplate = "high",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
c.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "eq-a", "eq-z" });
|
|
c.DriverInstancePlans.Single().DriverInstanceId.ShouldBe("drv-1");
|
|
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
|
|
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
|
|
/// <c>DependencyRefs</c> extracted from the script's <c>ctx.GetTag("…")</c> literals — the
|
|
/// artifact-decode mirror of <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseComposition_reads_EquipmentVirtualTags_from_virtualtags_and_scripts()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Scripts = new[]
|
|
{
|
|
new
|
|
{
|
|
ScriptId = "scr-1",
|
|
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
|
|
},
|
|
},
|
|
VirtualTags = new[]
|
|
{
|
|
new
|
|
{
|
|
VirtualTagId = "vt-1",
|
|
EquipmentId = "eq-1",
|
|
Name = "Doubled",
|
|
DataType = "Float",
|
|
ScriptId = "scr-1",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
|
|
vt.VirtualTagId.ShouldBe("vt-1");
|
|
vt.EquipmentId.ShouldBe("eq-1");
|
|
vt.Name.ShouldBe("Doubled");
|
|
vt.DataType.ShouldBe("Float");
|
|
vt.FolderPath.ShouldBe("");
|
|
vt.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
|
|
vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
|
|
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
|
|
/// equipment browses by its friendly UNS name. NodeId stays the logical EquipmentId.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseComposition_equipment_DisplayName_is_UNS_Name_not_MachineCode()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "eq-1", Name = "filling-eq", MachineCode = "FILLING-EQ", UnsLineId = "line-1" },
|
|
},
|
|
});
|
|
|
|
var node = DeploymentArtifact.ParseComposition(blob).EquipmentNodes.ShouldHaveSingleItem();
|
|
|
|
node.EquipmentId.ShouldBe("eq-1");
|
|
node.DisplayName.ShouldBe("filling-eq");
|
|
}
|
|
|
|
/// <summary>Verifies that specs missing required fields are dropped.</summary>
|
|
[Fact]
|
|
public void Spec_missing_required_fields_is_dropped()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
DriverInstances = new object[]
|
|
{
|
|
new { Name = "no-id" },
|
|
new
|
|
{
|
|
DriverInstanceId = "DI-ok",
|
|
DriverType = "Modbus",
|
|
DriverConfig = "{}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
|
|
|
specs.Single().DriverInstanceId.ShouldBe("DI-ok");
|
|
}
|
|
|
|
/// <summary>Verifies that a malformed blob resolves to None rather than throwing.</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_malformed_blob_returns_None()
|
|
{
|
|
var scope = DeploymentArtifact.ResolveClusterScope("not json"u8.ToArray(), "central-1:4053");
|
|
scope.Mode.ShouldBe(ClusterFilterMode.None);
|
|
}
|
|
|
|
/// <summary>Verifies that a blank ClusterId in the node row resolves to Suppress.</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_blank_cluster_id_suppresses()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "" } },
|
|
});
|
|
DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053").Mode.ShouldBe(ClusterFilterMode.Suppress);
|
|
}
|
|
|
|
/// <summary>Verifies that NodeId matching in ResolveClusterScope is case-insensitive.</summary>
|
|
[Fact]
|
|
public void ResolveClusterScope_node_id_match_is_case_insensitive()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[] { new { NodeId = "Central-1:4053", ClusterId = "MAIN" } },
|
|
});
|
|
var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053");
|
|
scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo);
|
|
scope.ClusterId.ShouldBe("MAIN");
|
|
}
|
|
|
|
private static object MultiClusterSnapshotWithTags() => new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
|
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
|
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
|
},
|
|
// Galaxy points are ordinary equipment tags now — Equipment-kind namespaces with non-null
|
|
// EquipmentId, so the cluster-scoped decode filters them via EquipmentTags (by their driver's
|
|
// cluster), exactly as it filtered the retired GalaxyTags.
|
|
Namespaces = new[]
|
|
{
|
|
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 },
|
|
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
|
|
},
|
|
Tags = new[]
|
|
{
|
|
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
|
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
|
},
|
|
};
|
|
|
|
[Fact]
|
|
public void ParseComposition_scoped_keeps_only_my_clusters_drivers()
|
|
{
|
|
// v3: DriverInstance cluster scoping still holds. The EquipmentTags leg of the original assertion
|
|
// is dark this batch (equipment-tag plans don't materialize until Batch 4) — see
|
|
// TagConfigCorpusParityTests + DarkAddressSpaceReasons.
|
|
var blob = BlobOf(MultiClusterSnapshotWithTags());
|
|
|
|
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
|
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
|
main.EquipmentTags.ShouldBeEmpty(); // dark this batch
|
|
|
|
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
|
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
|
|
siteA.EquipmentTags.ShouldBeEmpty(); // dark this batch
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseComposition_scoped_unknown_node_is_empty()
|
|
{
|
|
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
|
|
comp.EquipmentTags.ShouldBeEmpty();
|
|
comp.DriverInstancePlans.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId
|
|
/// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered).</summary>
|
|
[Fact]
|
|
public void ParseComposition_scoped_keeps_only_my_clusters_virtual_tags()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
|
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
|
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
|
},
|
|
// v3: equipment carries no driver binding — it is attributed to a cluster solely via its UNS
|
|
// line's area cluster (Equipment -> UnsLine.UnsAreaId -> UnsArea.ClusterId).
|
|
UnsAreas = new[]
|
|
{
|
|
new { UnsAreaId = "a-main", Name = "AreaMain", ClusterId = "MAIN" },
|
|
new { UnsAreaId = "a-sa", Name = "AreaSa", ClusterId = "SITE-A" },
|
|
},
|
|
UnsLines = new[]
|
|
{
|
|
new { UnsLineId = "l1", UnsAreaId = "a-main", Name = "L1" },
|
|
new { UnsLineId = "l2", UnsAreaId = "a-sa", Name = "L2" },
|
|
},
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1" },
|
|
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2" },
|
|
},
|
|
Scripts = new[]
|
|
{
|
|
new { ScriptId = "scr", SourceCode = "return 1;" },
|
|
},
|
|
VirtualTags = new[]
|
|
{
|
|
new { VirtualTagId = "vt-main", EquipmentId = "eq-main", Name = "VM", DataType = "Float", ScriptId = "scr" },
|
|
new { VirtualTagId = "vt-sa", EquipmentId = "eq-sa", Name = "VS", DataType = "Float", ScriptId = "scr" },
|
|
},
|
|
});
|
|
|
|
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
|
main.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-main" });
|
|
|
|
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
|
siteA.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-sa" });
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" } },
|
|
Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } },
|
|
DriverInstances = new[] { new { DriverInstanceId = "d1", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "ns" } },
|
|
});
|
|
DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count
|
|
.ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count);
|
|
}
|
|
|
|
/// <summary>An artifact where an equipment's driver is in the node's cluster but its UNS line's area
|
|
/// is in another cluster: <paramref name="areaCluster"/> controls the area's ClusterId.</summary>
|
|
private static byte[] OrphanEquipmentBlob(string areaCluster) => BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "main-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
|
},
|
|
UnsAreas = new[] { new { UnsAreaId = "area-1", Name = "Area1", ClusterId = areaCluster } },
|
|
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line1" } },
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "equip-1", Name = "Equip1", UnsLineId = "line-1", DriverInstanceId = "main-driver" },
|
|
},
|
|
});
|
|
|
|
/// <summary>Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to
|
|
/// another cluster (a cross-cluster orphan binding).</summary>
|
|
[Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)]
|
|
public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment()
|
|
{
|
|
var warnings = new List<string>();
|
|
var comp = DeploymentArtifact.ParseComposition(
|
|
OrphanEquipmentBlob(areaCluster: "SITE-A"), "central-1:4053", warnings.Add);
|
|
|
|
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" }); // still returned
|
|
warnings.Count.ShouldBe(1);
|
|
warnings[0].ShouldContain("equip-1");
|
|
warnings[0].ShouldContain("line-1");
|
|
}
|
|
|
|
/// <summary>Verifies the inconsistency callback does NOT fire when the equipment, its driver, and its
|
|
/// UNS line/area are all in the same cluster (the normal, invariant-respecting case).</summary>
|
|
[Fact]
|
|
public void ParseComposition_scoped_consistent_equipment_does_not_warn()
|
|
{
|
|
var warnings = new List<string>();
|
|
var comp = DeploymentArtifact.ParseComposition(
|
|
OrphanEquipmentBlob(areaCluster: "MAIN"), "central-1:4053", warnings.Add);
|
|
|
|
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" });
|
|
comp.UnsLines.Select(l => l.UnsLineId).ShouldBe(new[] { "line-1" });
|
|
warnings.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>A multi-cluster artifact with one driver-less equipment (no <c>DriverInstanceId</c>
|
|
/// property at all => null driver) attributed only via its UNS line's area cluster:
|
|
/// UnsArea A1 (ClusterId = <paramref name="areaCluster"/>), UnsLine L1 (UnsAreaId = A1),
|
|
/// Equipment E1 (UnsLineId = L1, no driver), plus an EquipmentVirtualTag on E1. The MAIN-cluster
|
|
/// node always carries a driver so the artifact is genuinely multi-cluster (scoped, not None).</summary>
|
|
private static byte[] DriverlessEquipmentBlob(string areaCluster) => BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "c1-1:4053", ClusterId = "C1" },
|
|
new { NodeId = "c2-1:4053", ClusterId = "C2" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
|
|
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
|
|
},
|
|
UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = areaCluster } },
|
|
UnsLines = new[] { new { UnsLineId = "L1", UnsAreaId = "A1", Name = "Line1" } },
|
|
// Driver-less equipment: the DriverInstanceId property is absent => the parser sees null.
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "E1", Name = "Equip1", UnsLineId = "L1" },
|
|
},
|
|
Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } },
|
|
VirtualTags = new[]
|
|
{
|
|
new { VirtualTagId = "vt-e1", EquipmentId = "E1", Name = "VE1", DataType = "Float", ScriptId = "scr" },
|
|
},
|
|
});
|
|
|
|
/// <summary>Verifies driver-less equipment (null DriverInstanceId) IS kept when its UNS line's area
|
|
/// is in the node's cluster — attributed via the line→area→cluster chain rather than via a driver.
|
|
/// Both the EquipmentNode and its EquipmentVirtualTag must survive the scoped parse.</summary>
|
|
[Fact]
|
|
public void Driverless_equipment_kept_when_its_line_area_is_in_cluster()
|
|
{
|
|
var comp = DeploymentArtifact.ParseComposition(
|
|
DriverlessEquipmentBlob(areaCluster: "C1"), "c1-1:4053");
|
|
|
|
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E1" });
|
|
comp.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-e1" });
|
|
}
|
|
|
|
/// <summary>Verifies driver-less equipment is EXCLUDED when its UNS line's area is in another cluster:
|
|
/// the node scopes to C1 but area A1 (and thus line L1, equipment E1) lives in C2.</summary>
|
|
[Fact]
|
|
public void Driverless_equipment_excluded_when_its_line_area_in_other_cluster()
|
|
{
|
|
var comp = DeploymentArtifact.ParseComposition(
|
|
DriverlessEquipmentBlob(areaCluster: "C2"), "c1-1:4053");
|
|
|
|
comp.EquipmentNodes.ShouldBeEmpty();
|
|
comp.EquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies driver-less equipment with no UnsLineId (null/absent) is EXCLUDED from the scoped
|
|
/// composition and throws nothing: without a line there is no area chain to resolve a cluster from.</summary>
|
|
[Fact]
|
|
public void Driverless_equipment_excluded_when_it_has_no_line()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "c1-1:4053", ClusterId = "C1" },
|
|
new { NodeId = "c2-1:4053", ClusterId = "C2" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
// One driver per cluster so the artifact is genuinely multi-cluster (scoped, not None).
|
|
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
|
|
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
|
|
},
|
|
UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = "C1" } },
|
|
// Equipment has no DriverInstanceId (driver-less) and no UnsLineId — cannot resolve a cluster.
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "E-noline", Name = "NoLine" },
|
|
},
|
|
Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } },
|
|
VirtualTags = new[]
|
|
{
|
|
new { VirtualTagId = "vt-noline", EquipmentId = "E-noline", Name = "VNL", DataType = "Float", ScriptId = "scr" },
|
|
},
|
|
});
|
|
|
|
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
|
|
|
|
comp.EquipmentNodes.ShouldBeEmpty();
|
|
comp.EquipmentVirtualTags.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>RETIRED (v3): equipment no longer carries a DriverInstanceId, so there is no driver-bound
|
|
/// attribution path — equipment is attributed to a cluster solely via its UNS line's area
|
|
/// (Driverless_equipment_kept_when_its_line_area_is_in_cluster is the v3 replacement, and passes).
|
|
/// The original premise ("attribution is still by driver") no longer exists.</summary>
|
|
[Fact(Skip = DarkAddressSpaceReasons.EquipmentDeviceBindingRetired)]
|
|
public void Driverbound_equipment_kept_when_driver_in_cluster()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "c1-1:4053", ClusterId = "C1" },
|
|
new { NodeId = "c2-1:4053", ClusterId = "C2" },
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
|
|
new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
|
|
},
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "E-c1", Name = "Eq-c1", UnsLineId = "l1", DriverInstanceId = "c1-driver" },
|
|
new { EquipmentId = "E-c2", Name = "Eq-c2", UnsLineId = "l2", DriverInstanceId = "c2-driver" },
|
|
},
|
|
});
|
|
|
|
var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
|
|
comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" });
|
|
}
|
|
|
|
/// <summary>Verifies the per-instance ResilienceConfig column is carried onto the parsed spec.</summary>
|
|
[Fact]
|
|
public void ParseDriverInstances_carries_ResilienceConfig_onto_the_spec()
|
|
{
|
|
var resilienceJson = "{\"bulkheadMaxConcurrent\":16,\"capabilityPolicies\":{\"Write\":{\"retryCount\":0}}}";
|
|
var blob = BlobOf(new
|
|
{
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "d1", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ResilienceConfig = resilienceJson },
|
|
},
|
|
});
|
|
|
|
var spec = DeploymentArtifact.ParseDriverInstances(blob).Single();
|
|
|
|
spec.ResilienceConfig.ShouldBe(resilienceJson);
|
|
}
|
|
|
|
/// <summary>Verifies an absent ResilienceConfig column leaves the spec field null (⇒ tier defaults at spawn).</summary>
|
|
[Fact]
|
|
public void ParseDriverInstances_absent_ResilienceConfig_is_null()
|
|
{
|
|
var blob = BlobOf(new
|
|
{
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "d1", DriverType = "Modbus", Enabled = true, DriverConfig = "{}" },
|
|
},
|
|
});
|
|
|
|
var spec = DeploymentArtifact.ParseDriverInstances(blob).Single();
|
|
|
|
spec.ResilienceConfig.ShouldBeNull();
|
|
}
|
|
}
|