Phase 1 Stream A — Core.Abstractions project + 11 capability interfaces + DriverTypeRegistry + interface-independence tests
New project src/ZB.MOM.WW.OtOpcUa.Core.Abstractions (.NET 10, BCL-only dependencies, GenerateDocumentationFile=true, TreatWarningsAsErrors=true) defining the contract surface every driver implements. Per docs/v2/plan.md decisions #4 (composable capability interfaces), #52 (streaming IAddressSpaceBuilder), #53 (capability discovery via `is` checks no flag enum), #54 (optional IRediscoverable sub-interface), #59 (Core.Abstractions internal-only for now design as if public). Eleven capability interfaces: - IDriver — required lifecycle / health / config-apply / memory-footprint accounting (per driver-stability.md Tier A/B allocation tracking) - ITagDiscovery — discovers tags streaming to IAddressSpaceBuilder - IReadable — on-demand reads idempotent for Polly retry - IWritable — writes NOT auto-retried by default per decisions #44 + #45 - ISubscribable — data-change subscriptions covering both native (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS) and driver-internal polled (Modbus, AB CIP, S7, FOCAS) mechanisms; OnDataChange callback regardless of source - IAlarmSource — alarm events + acknowledge + AlarmSeverity enum mirroring acl-design.md NodePermissions alarm-severity values - IHistoryProvider — HistoryReadRaw + HistoryReadProcessed with continuation points - IRediscoverable — opt-in change-detection signal; static drivers don't implement - IHostConnectivityProbe — generalized from Galaxy's GalaxyRuntimeProbeManager per plan §5a - IDriverConfigEditor — Admin UI plug-point for per-driver custom config editors deferred to each driver's phase per decision #27 - IAddressSpaceBuilder — streaming builder API for driver-driven address-space construction Plus DTOs: DriverDataType, SecurityClassification (mirroring v1 Galaxy model), DriverAttributeInfo (replaces Galaxy-specific GalaxyAttributeInfo per plan §5a), DriverHealth + DriverState, DataValueSnapshot (universal OPC UA quality + timestamp carrier per decision #13), HostConnectivityStatus + HostState + HostStatusChangedEventArgs, RediscoveryEventArgs, DataChangeEventArgs, AlarmEventArgs + AlarmAcknowledgeRequest + AlarmSeverity, WriteRequest + WriteResult, HistoryReadResult + HistoryAggregateType, ISubscriptionHandle + IAlarmSubscriptionHandle + IVariableHandle. DriverTypeRegistry singleton with Register / Get / TryGet / All; thread-safe via Interlocked.Exchange snapshot replacement on registration; case-insensitive lookups; rejects duplicate registrations; rejects empty type names. DriverTypeMetadata record carries TypeName + AllowedNamespaceKinds (NamespaceKindCompatibility flags enum per decision #111) + per-config-tier JSON Schemas the validator checks at draft-publish time (decision #91). Tests project tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests (xUnit v3 1.1.0 matching existing test projects). 24 tests covering: 1) interface independence reflection check (no references outside BCL/System; all public types in root namespace; every capability interface is public); 2) DriverTypeRegistry round-trip, case-insensitive lookups, KeyNotFoundException on unknown, null on TryGet of unknown, InvalidOperationException on duplicate registration (case-insensitive too), All() enumeration, NamespaceKindCompatibility bitmask combinations, ArgumentException on empty type names. Build: 0 errors, 4 warnings (only pre-existing transitive package vulnerability + analyzer hints). Full test suite: 845 passing / 1 failing — strict improvement over Phase 0 baseline (821/1) by the 24 new Core.Abstractions tests; no regressions in any other test project. Phase 1 entry-gate record (docs/v2/implementation/entry-gate-phase-1.md) documents the deviation: only Stream A executed in this continuation since Streams B-E need SQL Server / GLAuth / Galaxy infrastructure standup per dev-environment.md Step 1, which is currently TODO. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
public sealed class DriverTypeRegistryTests
|
||||
{
|
||||
private static DriverTypeMetadata SampleMetadata(
|
||||
string typeName = "Modbus",
|
||||
NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment) =>
|
||||
new(typeName, allowed,
|
||||
DriverConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
DeviceConfigJsonSchema: "{\"type\": \"object\"}",
|
||||
TagConfigJsonSchema: "{\"type\": \"object\"}");
|
||||
|
||||
[Fact]
|
||||
public void Register_ThenGet_RoundTrips()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
var metadata = SampleMetadata();
|
||||
|
||||
registry.Register(metadata);
|
||||
|
||||
registry.Get("Modbus").ShouldBe(metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Galaxy"));
|
||||
|
||||
registry.Get("galaxy").ShouldNotBeNull();
|
||||
registry.Get("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_UnknownType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_UnknownType_ReturnsNull()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
registry.TryGet("UnregisteredType").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateType_Throws()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_DuplicateTypeIsCaseInsensitive()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_ReturnsRegisteredTypes()
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
registry.Register(SampleMetadata("Modbus"));
|
||||
registry.Register(SampleMetadata("S7"));
|
||||
registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.SystemPlatform));
|
||||
|
||||
var all = registry.All();
|
||||
|
||||
all.Count.ShouldBe(3);
|
||||
all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamespaceKindCompatibility_FlagsAreBitmask()
|
||||
{
|
||||
// Per decision #111 — driver types like OpcUaClient may be valid for multiple namespace kinds.
|
||||
var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.SystemPlatform;
|
||||
|
||||
both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.SystemPlatform).ShouldBeTrue();
|
||||
both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Get_RejectsEmptyTypeName(string? typeName)
|
||||
{
|
||||
var registry = new DriverTypeRegistry();
|
||||
Should.Throw<ArgumentException>(() => registry.Get(typeName!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <c>Core.Abstractions</c> stays a true contract project — it must not depend on
|
||||
/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types.
|
||||
/// Per <c>docs/v2/plan.md</c> decision #59 (Core.Abstractions internal-only for now; design as
|
||||
/// if public to minimize churn later).
|
||||
/// </summary>
|
||||
public sealed class InterfaceIndependenceTests
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(IDriver).Assembly;
|
||||
|
||||
[Fact]
|
||||
public void Assembly_HasNoReferencesOutsideBcl()
|
||||
{
|
||||
// Allowed reference assembly name prefixes — BCL + the assembly itself.
|
||||
var allowed = new[]
|
||||
{
|
||||
"System",
|
||||
"Microsoft.Win32",
|
||||
"netstandard",
|
||||
"mscorlib",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
||||
};
|
||||
|
||||
var referenced = Assembly.GetReferencedAssemblies();
|
||||
var disallowed = referenced
|
||||
.Where(r => !allowed.Any(a => r.Name!.StartsWith(a, StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
|
||||
disallowed.ShouldBeEmpty(
|
||||
$"Core.Abstractions must reference only BCL/System assemblies. " +
|
||||
$"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllPublicTypes_LiveInRootNamespace()
|
||||
{
|
||||
// Per the decision-#59 "design as if public" rule — no nested sub-namespaces; one flat surface.
|
||||
var publicTypes = Assembly.GetExportedTypes();
|
||||
var nonRoot = publicTypes
|
||||
.Where(t => t.Namespace != "ZB.MOM.WW.OtOpcUa.Core.Abstractions")
|
||||
.ToList();
|
||||
|
||||
nonRoot.ShouldBeEmpty(
|
||||
$"Core.Abstractions should expose all public types in the root namespace. " +
|
||||
$"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(IDriver))]
|
||||
[InlineData(typeof(ITagDiscovery))]
|
||||
[InlineData(typeof(IReadable))]
|
||||
[InlineData(typeof(IWritable))]
|
||||
[InlineData(typeof(ISubscribable))]
|
||||
[InlineData(typeof(IAlarmSource))]
|
||||
[InlineData(typeof(IHistoryProvider))]
|
||||
[InlineData(typeof(IRediscoverable))]
|
||||
[InlineData(typeof(IHostConnectivityProbe))]
|
||||
[InlineData(typeof(IDriverConfigEditor))]
|
||||
[InlineData(typeof(IAddressSpaceBuilder))]
|
||||
public void EveryCapabilityInterface_IsPublic(Type type)
|
||||
{
|
||||
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
|
||||
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user