478 lines
20 KiB
C#
478 lines
20 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 tags (non-null EquipmentId in an
|
|
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
|
|
/// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A
|
|
/// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must
|
|
/// still route to GalaxyTags.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
|
|
{
|
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
Namespaces = new[]
|
|
{
|
|
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
|
|
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
|
|
},
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
|
new { DriverInstanceId = "drv-galaxy", DriverType = "Galaxy", DriverConfig = "{}", NamespaceId = "ns-sp" },
|
|
},
|
|
Tags = new object[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-eq",
|
|
DriverInstanceId = "drv-modbus",
|
|
EquipmentId = "eq-1",
|
|
Name = "Speed",
|
|
FolderPath = (string?)null,
|
|
DataType = "Float",
|
|
TagConfig = "{\"FullName\":\"40001\"}",
|
|
},
|
|
new
|
|
{
|
|
TagId = "tag-gx",
|
|
DriverInstanceId = "drv-galaxy",
|
|
EquipmentId = (string?)null,
|
|
Name = "Temp",
|
|
FolderPath = "area",
|
|
DataType = "Float",
|
|
TagConfig = "{\"FullName\":\"area.Temp\"}",
|
|
},
|
|
},
|
|
});
|
|
|
|
var c = DeploymentArtifact.ParseComposition(blob);
|
|
|
|
var tag = c.EquipmentTags.ShouldHaveSingleItem();
|
|
tag.TagId.ShouldBe("tag-eq");
|
|
tag.EquipmentId.ShouldBe("eq-1");
|
|
tag.DriverInstanceId.ShouldBe("drv-modbus");
|
|
tag.Name.ShouldBe("Speed");
|
|
tag.DataType.ShouldBe("Float");
|
|
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
|
|
|
|
// The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags.
|
|
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
|
|
}
|
|
|
|
/// <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>Phase7Composer.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" },
|
|
},
|
|
Namespaces = new[]
|
|
{
|
|
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 },
|
|
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 },
|
|
},
|
|
Tags = new[]
|
|
{
|
|
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
|
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
|
|
},
|
|
};
|
|
|
|
[Fact]
|
|
public void ParseComposition_scoped_keeps_only_my_clusters_drivers_and_tags()
|
|
{
|
|
var blob = BlobOf(MultiClusterSnapshotWithTags());
|
|
|
|
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
|
|
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
|
main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
|
|
|
|
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
|
|
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
|
|
siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseComposition_scoped_unknown_node_is_empty()
|
|
{
|
|
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
|
|
comp.GalaxyTags.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" },
|
|
},
|
|
Equipment = new[]
|
|
{
|
|
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
|
|
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
|
|
},
|
|
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);
|
|
}
|
|
}
|