chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,100 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Alarms;
/// <summary>
/// Contract tests for the <see cref="AlarmConditionInfo"/> record extension added in PR 2.1.
/// Five sub-attribute references (InAlarmRef, PriorityRef, DescAttrNameRef, AckedRef,
/// AckMsgWriteRef) carry the driver-side tag references the server-level alarm-condition
/// service uses to subscribe to live alarm-state attributes and route ack writes.
/// </summary>
public sealed class AlarmConditionInfoTests
{
[Fact]
public void LegacyThreeArgConstructor_StillCompiles_AndDefaultsRefsToNull()
{
var info = new AlarmConditionInfo(
SourceName: "Tank.HiHi",
InitialSeverity: AlarmSeverity.High,
InitialDescription: "High-high alarm");
info.SourceName.ShouldBe("Tank.HiHi");
info.InitialSeverity.ShouldBe(AlarmSeverity.High);
info.InitialDescription.ShouldBe("High-high alarm");
info.InAlarmRef.ShouldBeNull();
info.PriorityRef.ShouldBeNull();
info.DescAttrNameRef.ShouldBeNull();
info.AckedRef.ShouldBeNull();
info.AckMsgWriteRef.ShouldBeNull();
}
[Fact]
public void FullConstructor_PopulatesAllFiveSubAttributeRefs()
{
var info = new AlarmConditionInfo(
SourceName: "Tank1.HiAlarm",
InitialSeverity: AlarmSeverity.Medium,
InitialDescription: "Tank level high",
InAlarmRef: "Tank1.HiAlarm.InAlarm",
PriorityRef: "Tank1.HiAlarm.Priority",
DescAttrNameRef: "Tank1.HiAlarm.DescAttrName",
AckedRef: "Tank1.HiAlarm.Acked",
AckMsgWriteRef: "Tank1.HiAlarm.AckMsg");
info.InAlarmRef.ShouldBe("Tank1.HiAlarm.InAlarm");
info.PriorityRef.ShouldBe("Tank1.HiAlarm.Priority");
info.DescAttrNameRef.ShouldBe("Tank1.HiAlarm.DescAttrName");
info.AckedRef.ShouldBe("Tank1.HiAlarm.Acked");
info.AckMsgWriteRef.ShouldBe("Tank1.HiAlarm.AckMsg");
}
[Fact]
public void RecordEquality_ComparesAllEightFields()
{
var a = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
"T.Alarm.Acked", "T.Alarm.AckMsg");
var b = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
"T.Alarm.Acked", "T.Alarm.AckMsg");
a.ShouldBe(b);
}
[Fact]
public void RecordEquality_DistinctWhenAnyRefDiffers()
{
var baseInfo = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
InAlarmRef: "T.Alarm.InAlarm");
var differingAckRef = baseInfo with { AckedRef = "T.Alarm.Acked" };
baseInfo.ShouldNotBe(differingAckRef);
}
[Fact]
public void WithExpression_AllowsPartialUpdates()
{
var legacy = new AlarmConditionInfo("S", AlarmSeverity.Medium, null);
var enriched = legacy with
{
InAlarmRef = "S.InAlarm",
AckedRef = "S.Acked",
AckMsgWriteRef = "S.AckMsg",
};
enriched.SourceName.ShouldBe("S");
enriched.InAlarmRef.ShouldBe("S.InAlarm");
enriched.PriorityRef.ShouldBeNull();
enriched.DescAttrNameRef.ShouldBeNull();
enriched.AckedRef.ShouldBe("S.Acked");
enriched.AckMsgWriteRef.ShouldBe("S.AckMsg");
}
}

View File

@@ -0,0 +1,123 @@
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,
DriverTier tier = DriverTier.B) =>
new(typeName, allowed,
DriverConfigJsonSchema: "{\"type\": \"object\"}",
DeviceConfigJsonSchema: "{\"type\": \"object\"}",
TagConfigJsonSchema: "{\"type\": \"object\"}",
Tier: tier);
[Fact]
public void Register_ThenGet_RoundTrips()
{
var registry = new DriverTypeRegistry();
var metadata = SampleMetadata();
registry.Register(metadata);
registry.Get("Modbus").ShouldBe(metadata);
}
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
[InlineData(DriverTier.C)]
public void Register_Requires_NonNullTier(DriverTier tier)
{
var registry = new DriverTypeRegistry();
var metadata = SampleMetadata(typeName: $"Driver-{tier}", tier: tier);
registry.Register(metadata);
registry.Get(metadata.TypeName).Tier.ShouldBe(tier);
}
[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!));
}
}

View File

@@ -0,0 +1,121 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
/// <summary>
/// Structural contract tests for the historian data-source surface added in PR 1.1.
/// Asserts the type shape — implementations are tested in their own projects.
/// </summary>
public sealed class IHistorianDataSourceContractTests
{
[Fact]
public void Interface_LivesInRootNamespace()
{
typeof(IHistorianDataSource).Namespace
.ShouldBe("ZB.MOM.WW.OtOpcUa.Core.Abstractions");
}
[Fact]
public void Interface_IsPublic()
{
typeof(IHistorianDataSource).IsPublic.ShouldBeTrue();
typeof(IHistorianDataSource).IsInterface.ShouldBeTrue();
}
[Fact]
public void Interface_ExtendsIDisposable()
{
typeof(IDisposable).IsAssignableFrom(typeof(IHistorianDataSource))
.ShouldBeTrue("data sources own backend connections; the server disposes them on shutdown");
}
[Theory]
[InlineData("ReadRawAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadProcessedAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadAtTimeAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
{
var method = typeof(IHistorianDataSource).GetMethod(methodName);
method.ShouldNotBeNull();
method!.ReturnType.ShouldBe(expectedReturnType);
}
[Fact]
public void GetHealthSnapshot_IsSynchronous()
{
var method = typeof(IHistorianDataSource).GetMethod("GetHealthSnapshot");
method.ShouldNotBeNull();
method!.ReturnType.ShouldBe(typeof(HistorianHealthSnapshot));
}
[Fact]
public void HealthSnapshot_AcceptsEmptyClusterNodeList()
{
var snapshot = new HistorianHealthSnapshot(
TotalQueries: 0,
TotalSuccesses: 0,
TotalFailures: 0,
ConsecutiveFailures: 0,
LastSuccessTime: null,
LastFailureTime: null,
LastError: null,
ProcessConnectionOpen: false,
EventConnectionOpen: false,
ActiveProcessNode: null,
ActiveEventNode: null,
Nodes: Array.Empty<HistorianClusterNodeState>());
snapshot.Nodes.ShouldBeEmpty();
}
[Fact]
public void HealthSnapshot_PreservesClusterNodes()
{
var node = new HistorianClusterNodeState(
Name: "hist-01",
IsHealthy: true,
CooldownUntil: null,
FailureCount: 0,
LastError: null,
LastFailureTime: null);
var snapshot = new HistorianHealthSnapshot(
TotalQueries: 5,
TotalSuccesses: 5,
TotalFailures: 0,
ConsecutiveFailures: 0,
LastSuccessTime: new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc),
LastFailureTime: null,
LastError: null,
ProcessConnectionOpen: true,
EventConnectionOpen: true,
ActiveProcessNode: "hist-01",
ActiveEventNode: "hist-01",
Nodes: new[] { node });
snapshot.Nodes.Count.ShouldBe(1);
snapshot.Nodes[0].ShouldBe(node);
}
[Fact]
public void ClusterNodeState_RecordEqualityByValue()
{
var a = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
var b = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
a.ShouldBe(b);
}
[Fact]
public void ClusterNodeState_DistinctByAnyField()
{
var healthy = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
var unhealthy = new HistorianClusterNodeState("hist-01", false, null, 1, "boom", null);
healthy.ShouldNotBe(unhealthy);
}
}

View File

@@ -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.");
}
}

View File

@@ -0,0 +1,245 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
[Trait("Category", "Unit")]
public sealed class PollGroupEngineTests
{
private sealed class FakeSource
{
public ConcurrentDictionary<string, object?> Values { get; } = new();
public int ReadCount;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> refs, CancellationToken ct)
{
Interlocked.Increment(ref ReadCount);
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> snapshots = refs
.Select(r => Values.TryGetValue(r, out var v)
? new DataValueSnapshot(v, 0u, now, now)
: new DataValueSnapshot(null, 0x80340000u, null, now))
.ToList();
return Task.FromResult(snapshots);
}
}
[Fact]
public async Task Initial_poll_force_raises_every_subscribed_tag()
{
var src = new FakeSource();
src.Values["A"] = 1;
src.Values["B"] = "hello";
var events = new ConcurrentQueue<(ISubscriptionHandle h, string r, DataValueSnapshot s)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["A", "B"], TimeSpan.FromMilliseconds(200));
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
events.Select(e => e.r).ShouldBe(["A", "B"], ignoreOrder: true);
engine.Unsubscribe(handle).ShouldBeTrue();
}
[Fact]
public async Task Unchanged_value_raises_only_once()
{
var src = new FakeSource();
src.Values["X"] = 42;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await Task.Delay(500);
engine.Unsubscribe(handle);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Value_change_raises_new_event()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
src.Values["X"] = 2;
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
events.Last().Item3.Value.ShouldBe(2);
}
[Fact]
public async Task Unsubscribe_halts_the_loop()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
engine.Unsubscribe(handle).ShouldBeTrue();
var afterUnsub = events.Count;
src.Values["X"] = 999;
await Task.Delay(400);
events.Count.ShouldBe(afterUnsub);
}
[Fact]
public async Task Interval_below_floor_is_clamped()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)),
minInterval: TimeSpan.FromMilliseconds(200));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(5));
await Task.Delay(300);
engine.Unsubscribe(handle);
// 300 ms window, 200 ms floor, stable value → initial push + at most 1 extra poll.
// With zero changes only the initial-data push fires.
events.Count.ShouldBe(1);
}
[Fact]
public async Task Multiple_subscriptions_are_independent()
{
var src = new FakeSource();
src.Values["A"] = 1;
src.Values["B"] = 2;
var a = new ConcurrentQueue<string>();
var b = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) =>
{
if (r == "A") a.Enqueue(r);
else if (r == "B") b.Enqueue(r);
});
var ha = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(100));
var hb = engine.Subscribe(["B"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => a.Count >= 1 && b.Count >= 1, TimeSpan.FromSeconds(2));
engine.Unsubscribe(ha);
var aCount = a.Count;
src.Values["B"] = 77;
await WaitForAsync(() => b.Count >= 2, TimeSpan.FromSeconds(2));
a.Count.ShouldBe(aCount);
b.Count.ShouldBeGreaterThanOrEqualTo(2);
engine.Unsubscribe(hb);
}
[Fact]
public async Task Reader_exception_does_not_crash_loop()
{
var throwCount = 0;
var readCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
if (Interlocked.Increment(ref readCount) <= 2)
{
Interlocked.Increment(ref throwCount);
throw new InvalidOperationException("boom");
}
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(r => new DataValueSnapshot(1, 0u, now, now)).ToList());
}
var events = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(Reader,
(h, r, s) => events.Enqueue(r));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
throwCount.ShouldBe(2);
events.Count.ShouldBeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task Unsubscribe_unknown_handle_returns_false()
{
var src = new FakeSource();
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
var foreign = new DummyHandle();
engine.Unsubscribe(foreign).ShouldBeFalse();
}
[Fact]
public async Task ActiveSubscriptionCount_tracks_lifecycle()
{
var src = new FakeSource();
src.Values["X"] = 1;
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
engine.ActiveSubscriptionCount.ShouldBe(0);
var h1 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
var h2 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
engine.ActiveSubscriptionCount.ShouldBe(2);
engine.Unsubscribe(h1);
engine.ActiveSubscriptionCount.ShouldBe(1);
engine.Unsubscribe(h2);
engine.ActiveSubscriptionCount.ShouldBe(0);
}
[Fact]
public async Task DisposeAsync_cancels_all_subscriptions()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<string>();
var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue(r));
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
await engine.DisposeAsync();
engine.ActiveSubscriptionCount.ShouldBe(0);
var afterDispose = events.Count;
await Task.Delay(300);
// After dispose no more events — everything is cancelled.
events.Count.ShouldBe(afterDispose);
}
private sealed record DummyHandle : ISubscriptionHandle
{
public string DiagnosticId => "dummy";
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

View File

@@ -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\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>