Initial import of the CBDDC codebase with docs and tests. Add a .NET-focused gitignore to keep generated artifacts out of source control.
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
587
tests/ZB.MOM.WW.CBDDC.E2E.Tests/ClusterCrudSyncE2ETests.cs
Normal file
587
tests/ZB.MOM.WW.CBDDC.E2E.Tests/ClusterCrudSyncE2ETests.cs
Normal file
@@ -0,0 +1,587 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.CBDDC.Core;
|
||||
using ZB.MOM.WW.CBDDC.Core.Network;
|
||||
using ZB.MOM.WW.CBDDC.Core.Storage;
|
||||
using ZB.MOM.WW.CBDDC.Network;
|
||||
using ZB.MOM.WW.CBDDC.Network.Security;
|
||||
using ZB.MOM.WW.CBDDC.Persistence.BLite;
|
||||
|
||||
namespace ZB.MOM.WW.CBDDC.E2E.Tests;
|
||||
|
||||
public class ClusterCrudSyncE2ETests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies two real peers replicate create, update, and delete operations in both directions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TwoPeers_ShouldReplicateCrudBidirectionally()
|
||||
{
|
||||
var clusterToken = Guid.NewGuid().ToString("N");
|
||||
var nodeAPort = GetAvailableTcpPort();
|
||||
var nodeBPort = GetAvailableTcpPort();
|
||||
while (nodeBPort == nodeAPort)
|
||||
{
|
||||
nodeBPort = GetAvailableTcpPort();
|
||||
}
|
||||
|
||||
await using var nodeA = TestPeerNode.Create(
|
||||
nodeId: "node-a",
|
||||
tcpPort: nodeAPort,
|
||||
authToken: clusterToken,
|
||||
knownPeers:
|
||||
[
|
||||
new KnownPeerConfiguration
|
||||
{
|
||||
NodeId = "node-b",
|
||||
Host = "127.0.0.1",
|
||||
Port = nodeBPort
|
||||
}
|
||||
]);
|
||||
|
||||
await using var nodeB = TestPeerNode.Create(
|
||||
nodeId: "node-b",
|
||||
tcpPort: nodeBPort,
|
||||
authToken: clusterToken,
|
||||
knownPeers:
|
||||
[
|
||||
new KnownPeerConfiguration
|
||||
{
|
||||
NodeId = "node-a",
|
||||
Host = "127.0.0.1",
|
||||
Port = nodeAPort
|
||||
}
|
||||
]);
|
||||
|
||||
await nodeA.StartAsync();
|
||||
await nodeB.StartAsync();
|
||||
|
||||
const int timeoutSeconds = 45;
|
||||
|
||||
var nodeAUserId = "user-from-a";
|
||||
await nodeA.UpsertUserAsync(new User
|
||||
{
|
||||
Id = nodeAUserId,
|
||||
Name = "Alice",
|
||||
Age = 30,
|
||||
Address = new Address { City = "Austin" }
|
||||
});
|
||||
|
||||
await AssertEventuallyAsync(() =>
|
||||
{
|
||||
var replicated = nodeB.ReadUser(nodeAUserId);
|
||||
return replicated is not null
|
||||
&& replicated.Name == "Alice"
|
||||
&& replicated.Age == 30
|
||||
&& replicated.Address?.City == "Austin";
|
||||
}, timeoutSeconds, "Node B did not receive create from node A.", () => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await AssertEventuallyAsync(
|
||||
() => nodeA.ReadUser(nodeAUserId) is not null,
|
||||
timeoutSeconds,
|
||||
"Node A could not read back its own created user.",
|
||||
() => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await nodeA.DeleteUserAsync(nodeAUserId);
|
||||
|
||||
await nodeA.UpsertUserAsync(new User
|
||||
{
|
||||
Id = nodeAUserId,
|
||||
Name = "Alice Updated",
|
||||
Age = 31,
|
||||
Address = new Address { City = "Dallas" }
|
||||
});
|
||||
|
||||
await AssertEventuallyAsync(() =>
|
||||
{
|
||||
var replicated = nodeB.ReadUser(nodeAUserId);
|
||||
return replicated is not null
|
||||
&& replicated.Name == "Alice Updated"
|
||||
&& replicated.Age == 31
|
||||
&& replicated.Address?.City == "Dallas";
|
||||
}, timeoutSeconds, "Node B did not receive update from node A.", () => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await nodeA.DeleteUserAsync(nodeAUserId);
|
||||
|
||||
await AssertEventuallyAsync(
|
||||
() => nodeB.ReadUser(nodeAUserId) is null,
|
||||
timeoutSeconds,
|
||||
"Node B did not receive delete from node A.",
|
||||
() => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
var nodeBUserId = "user-from-b";
|
||||
await nodeB.UpsertUserAsync(new User
|
||||
{
|
||||
Id = nodeBUserId,
|
||||
Name = "Bob",
|
||||
Age = 40,
|
||||
Address = new Address { City = "Boston" }
|
||||
});
|
||||
|
||||
await AssertEventuallyAsync(() =>
|
||||
{
|
||||
var replicated = nodeA.ReadUser(nodeBUserId);
|
||||
return replicated is not null
|
||||
&& replicated.Name == "Bob"
|
||||
&& replicated.Age == 40
|
||||
&& replicated.Address?.City == "Boston";
|
||||
}, timeoutSeconds, "Node A did not receive create from node B.", () => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await AssertEventuallyAsync(
|
||||
() => nodeB.ReadUser(nodeBUserId) is not null,
|
||||
timeoutSeconds,
|
||||
"Node B could not read back its own created user.",
|
||||
() => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await nodeB.DeleteUserAsync(nodeBUserId);
|
||||
|
||||
await nodeB.UpsertUserAsync(new User
|
||||
{
|
||||
Id = nodeBUserId,
|
||||
Name = "Bob Updated",
|
||||
Age = 41,
|
||||
Address = new Address { City = "Denver" }
|
||||
});
|
||||
|
||||
await AssertEventuallyAsync(() =>
|
||||
{
|
||||
var replicated = nodeA.ReadUser(nodeBUserId);
|
||||
return replicated is not null
|
||||
&& replicated.Name == "Bob Updated"
|
||||
&& replicated.Age == 41
|
||||
&& replicated.Address?.City == "Denver";
|
||||
}, timeoutSeconds, "Node A did not receive update from node B.", () => BuildDiagnostics(nodeA, nodeB));
|
||||
|
||||
await nodeB.DeleteUserAsync(nodeBUserId);
|
||||
|
||||
await AssertEventuallyAsync(
|
||||
() => nodeA.ReadUser(nodeBUserId) is null,
|
||||
timeoutSeconds,
|
||||
"Node A did not receive delete from node B.",
|
||||
() => BuildDiagnostics(nodeA, nodeB));
|
||||
}
|
||||
|
||||
private static async Task AssertEventuallyAsync(
|
||||
Func<bool> predicate,
|
||||
int timeoutSeconds,
|
||||
string failureMessage,
|
||||
Func<string>? diagnostics = null)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||
var startedAt = DateTime.UtcNow;
|
||||
while (DateTime.UtcNow - startedAt < timeout)
|
||||
{
|
||||
if (predicate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(250);
|
||||
}
|
||||
|
||||
var suffix = diagnostics is null ? string.Empty : $"{Environment.NewLine}{diagnostics()}";
|
||||
throw new Shouldly.ShouldAssertException($"{failureMessage}{suffix}");
|
||||
}
|
||||
|
||||
private static string BuildDiagnostics(TestPeerNode nodeA, TestPeerNode nodeB)
|
||||
{
|
||||
var nodeAUserCount = nodeA.Context.Users.FindAll().Count();
|
||||
var nodeBUserCount = nodeB.Context.Users.FindAll().Count();
|
||||
var nodeAOplogCount = nodeA.Context.OplogEntries.FindAll().Count();
|
||||
var nodeBOplogCount = nodeB.Context.OplogEntries.FindAll().Count();
|
||||
var nodeAOplogByAuthor = string.Join(
|
||||
", ",
|
||||
nodeA.Context.OplogEntries.FindAll()
|
||||
.GroupBy(e => e.TimestampNodeId)
|
||||
.Select(g => $"{g.Key}:{g.Count()}"));
|
||||
var nodeBOplogByAuthor = string.Join(
|
||||
", ",
|
||||
nodeB.Context.OplogEntries.FindAll()
|
||||
.GroupBy(e => e.TimestampNodeId)
|
||||
.Select(g => $"{g.Key}:{g.Count()}"));
|
||||
var nodeAUsers = string.Join(", ", nodeA.Context.Users.FindAll().Select(u => $"{u.Id}:{u.Name}:{u.Age}:{u.Address?.City}"));
|
||||
var nodeBUsers = string.Join(", ", nodeB.Context.Users.FindAll().Select(u => $"{u.Id}:{u.Name}:{u.Age}:{u.Address?.City}"));
|
||||
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
"Diagnostics:",
|
||||
$"NodeA users={nodeAUserCount}, oplog={nodeAOplogCount}",
|
||||
$"NodeA users detail={nodeAUsers}",
|
||||
$"NodeA oplog by author={nodeAOplogByAuthor}",
|
||||
$"NodeB users={nodeBUserCount}, oplog={nodeBOplogCount}",
|
||||
$"NodeB users detail={nodeBUsers}",
|
||||
$"NodeB oplog by author={nodeBOplogByAuthor}",
|
||||
"NodeA logs:",
|
||||
nodeA.GetRecentLogs(),
|
||||
"NodeB logs:",
|
||||
nodeB.GetRecentLogs());
|
||||
}
|
||||
|
||||
private static int GetAvailableTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private sealed class TestPeerNode : IAsyncDisposable
|
||||
{
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly ICBDDCNode _node;
|
||||
private readonly IOplogStore _oplogStore;
|
||||
private readonly string _nodeId;
|
||||
private readonly string _workDir;
|
||||
private readonly InMemoryLogSink _logSink;
|
||||
private bool _started;
|
||||
private long _lastPhysicalTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
private int _logicalCounter;
|
||||
|
||||
public SampleDbContext Context { get; }
|
||||
|
||||
private TestPeerNode(
|
||||
ServiceProvider services,
|
||||
ICBDDCNode node,
|
||||
IOplogStore oplogStore,
|
||||
SampleDbContext context,
|
||||
InMemoryLogSink logSink,
|
||||
string workDir,
|
||||
string nodeId)
|
||||
{
|
||||
_services = services;
|
||||
_node = node;
|
||||
_oplogStore = oplogStore;
|
||||
Context = context;
|
||||
_logSink = logSink;
|
||||
_workDir = workDir;
|
||||
_nodeId = nodeId;
|
||||
}
|
||||
|
||||
public static TestPeerNode Create(
|
||||
string nodeId,
|
||||
int tcpPort,
|
||||
string authToken,
|
||||
IReadOnlyList<KnownPeerConfiguration> knownPeers)
|
||||
{
|
||||
var workDir = Path.Combine(Path.GetTempPath(), $"cbddc-e2e-{nodeId}-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(workDir);
|
||||
var dbPath = Path.Combine(workDir, "node.blite");
|
||||
|
||||
var configProvider = new StaticPeerNodeConfigurationProvider(new PeerNodeConfiguration
|
||||
{
|
||||
NodeId = nodeId,
|
||||
TcpPort = tcpPort,
|
||||
AuthToken = authToken,
|
||||
KnownPeers = knownPeers.ToList()
|
||||
});
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(new InMemoryLogSink(nodeId));
|
||||
services.AddSingleton<ILoggerProvider, InMemoryLoggerProvider>();
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
services.AddSingleton(configProvider);
|
||||
services.AddSingleton<IPeerNodeConfigurationProvider>(configProvider);
|
||||
services.AddCBDDCCore()
|
||||
.AddCBDDCBLite<SampleDbContext, SampleDocumentStore>(_ => new SampleDbContext(dbPath))
|
||||
.AddCBDDCNetwork<StaticPeerNodeConfigurationProvider>(useHostedService: false);
|
||||
|
||||
// Deterministic tests: sync uses explicit known peers, so disable UDP discovery.
|
||||
services.AddSingleton<IDiscoveryService, PassiveDiscoveryService>();
|
||||
services.AddSingleton<IPeerHandshakeService, NoOpHandshakeService>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var node = provider.GetRequiredService<ICBDDCNode>();
|
||||
var oplogStore = provider.GetRequiredService<IOplogStore>();
|
||||
var context = provider.GetRequiredService<SampleDbContext>();
|
||||
var logSink = provider.GetRequiredService<InMemoryLogSink>();
|
||||
return new TestPeerNode(provider, node, oplogStore, context, logSink, workDir, nodeId);
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _node.Start();
|
||||
_started = true;
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _node.Stop();
|
||||
_started = false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await StopAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_services.Dispose();
|
||||
TryDeleteDirectory(_workDir);
|
||||
}
|
||||
|
||||
public User? ReadUser(string userId)
|
||||
{
|
||||
return Context.Users.Find(u => u.Id == userId).FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task UpsertUserAsync(User user)
|
||||
{
|
||||
await PersistUserMutationWithOplogFallbackAsync(
|
||||
user.Id,
|
||||
OperationType.Put,
|
||||
JsonSerializer.SerializeToElement(user),
|
||||
async () =>
|
||||
{
|
||||
var existing = Context.Users.Find(u => u.Id == user.Id).FirstOrDefault();
|
||||
if (existing == null)
|
||||
{
|
||||
await Context.Users.InsertAsync(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Context.Users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(string userId)
|
||||
{
|
||||
await PersistUserMutationWithOplogFallbackAsync(
|
||||
userId,
|
||||
OperationType.Delete,
|
||||
payload: null,
|
||||
async () =>
|
||||
{
|
||||
await Context.Users.DeleteAsync(userId);
|
||||
await Context.SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
public string GetRecentLogs(int max = 50)
|
||||
{
|
||||
return _logSink.GetRecent(max);
|
||||
}
|
||||
|
||||
private async Task PersistUserMutationWithOplogFallbackAsync(
|
||||
string userId,
|
||||
OperationType operationType,
|
||||
JsonElement? payload,
|
||||
Func<Task> mutation)
|
||||
{
|
||||
var oplogCountBefore = Context.OplogEntries.FindAll().Count();
|
||||
await mutation();
|
||||
|
||||
// Prefer native CDC path; fallback only when CDC fails to emit.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(3);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (Context.OplogEntries.FindAll().Count() > oplogCountBefore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
var previousHash = await _oplogStore.GetLastEntryHashAsync(_nodeId) ?? string.Empty;
|
||||
var fallbackEntry = new OplogEntry(
|
||||
collection: "Users",
|
||||
key: userId,
|
||||
operation: operationType,
|
||||
payload: payload,
|
||||
timestamp: NextTimestamp(),
|
||||
previousHash: previousHash);
|
||||
|
||||
await _oplogStore.AppendOplogEntryAsync(fallbackEntry);
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private HlcTimestamp NextTimestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (now > _lastPhysicalTime)
|
||||
{
|
||||
_lastPhysicalTime = now;
|
||||
_logicalCounter = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logicalCounter++;
|
||||
}
|
||||
|
||||
return new HlcTimestamp(_lastPhysicalTime, _logicalCounter, _nodeId);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PassiveDiscoveryService : IDiscoveryService
|
||||
{
|
||||
public IEnumerable<PeerNode> GetActivePeers()
|
||||
{
|
||||
return Array.Empty<PeerNode>();
|
||||
}
|
||||
|
||||
public Task Start()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Stop()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StaticPeerNodeConfigurationProvider : IPeerNodeConfigurationProvider
|
||||
{
|
||||
private PeerNodeConfiguration _configuration;
|
||||
|
||||
public StaticPeerNodeConfigurationProvider(PeerNodeConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public event PeerNodeConfigurationChangedEventHandler? ConfigurationChanged;
|
||||
|
||||
public Task<PeerNodeConfiguration> GetConfiguration()
|
||||
{
|
||||
return Task.FromResult(_configuration);
|
||||
}
|
||||
|
||||
public void Update(PeerNodeConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
ConfigurationChanged?.Invoke(this, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLogSink
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _entries = new();
|
||||
private readonly string _nodeId;
|
||||
|
||||
public InMemoryLogSink(string nodeId)
|
||||
{
|
||||
_nodeId = nodeId;
|
||||
}
|
||||
|
||||
public void Add(string category, LogLevel level, string message, Exception? exception)
|
||||
{
|
||||
var text = $"[{DateTime.UtcNow:O}] {_nodeId} {level} {category}: {message}";
|
||||
if (exception is not null)
|
||||
{
|
||||
text = $"{text}{Environment.NewLine}{exception}";
|
||||
}
|
||||
|
||||
_entries.Enqueue(text);
|
||||
while (_entries.Count > 500 && _entries.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public string GetRecent(int max)
|
||||
{
|
||||
var entries = _entries.ToArray();
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
return "<no logs>";
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, entries.TakeLast(max));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly InMemoryLogSink _sink;
|
||||
|
||||
public InMemoryLoggerProvider(InMemoryLogSink sink)
|
||||
{
|
||||
_sink = sink;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
return new InMemoryLogger(categoryName, _sink);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLogger : ILogger
|
||||
{
|
||||
private readonly string _categoryName;
|
||||
private readonly InMemoryLogSink _sink;
|
||||
|
||||
public InMemoryLogger(string categoryName, InMemoryLogSink sink)
|
||||
{
|
||||
_categoryName = categoryName;
|
||||
_sink = sink;
|
||||
}
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||
{
|
||||
return NullScope.Instance;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_sink.Add(_categoryName, logLevel, formatter(state, exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
2
tests/ZB.MOM.WW.CBDDC.E2E.Tests/GlobalUsings.cs
Normal file
2
tests/ZB.MOM.WW.CBDDC.E2E.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1,2 @@
|
||||
global using Shouldly;
|
||||
global using ZB.MOM.WW.CBDDC.Sample.Console;
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>ZB.MOM.WW.CBDDC.E2E.Tests</AssemblyName>
|
||||
<RootNamespace>ZB.MOM.WW.CBDDC.E2E.Tests</RootNamespace>
|
||||
<PackageId>ZB.MOM.WW.CBDDC.E2E.Tests</PackageId>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>$(NoWarn);xUnit1031;xUnit1051</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\samples\ZB.MOM.WW.CBDDC.Sample.Console\ZB.MOM.WW.CBDDC.Sample.Console.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Core\ZB.MOM.WW.CBDDC.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Network\ZB.MOM.WW.CBDDC.Network.csproj" />
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.CBDDC.Persistence\ZB.MOM.WW.CBDDC.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user