227 lines
7.6 KiB
C#
227 lines
7.6 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using ZB.MOM.WW.CBDD.Bson;
|
|
using ZB.MOM.WW.CBDD.Core;
|
|
using ZB.MOM.WW.CBDD.Core.Collections;
|
|
using ZB.MOM.WW.CBDD.Core.Indexing;
|
|
using ZB.MOM.WW.CBDD.Core.Storage;
|
|
|
|
namespace ZB.MOM.WW.CBDD.Tests;
|
|
|
|
public class ArchitectureFitnessTests
|
|
{
|
|
private const string BsonProject = "src/CBDD.Bson/ZB.MOM.WW.CBDD.Bson.csproj";
|
|
private const string CoreProject = "src/CBDD.Core/ZB.MOM.WW.CBDD.Core.csproj";
|
|
private const string SourceGeneratorsProject = "src/CBDD.SourceGenerators/ZB.MOM.WW.CBDD.SourceGenerators.csproj";
|
|
private const string FacadeProject = "src/CBDD/ZB.MOM.WW.CBDD.csproj";
|
|
|
|
/// <summary>
|
|
/// Executes Solution_DependencyGraph_ShouldRemainAcyclic_AndFollowLayerDirection.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Solution_DependencyGraph_ShouldRemainAcyclic_AndFollowLayerDirection()
|
|
{
|
|
var repoRoot = FindRepositoryRoot();
|
|
var projectGraph = LoadSolutionProjectGraph(repoRoot);
|
|
|
|
// Explicit layer rules
|
|
projectGraph[BsonProject].ShouldBeEmpty();
|
|
projectGraph[SourceGeneratorsProject].ShouldBeEmpty();
|
|
projectGraph[CoreProject].ShouldBe(new[] { BsonProject });
|
|
projectGraph[FacadeProject]
|
|
.OrderBy(v => v, StringComparer.Ordinal)
|
|
.ShouldBe(new[] { BsonProject, CoreProject, SourceGeneratorsProject }.OrderBy(v => v, StringComparer.Ordinal));
|
|
|
|
// Source projects should not depend on tests.
|
|
foreach (var kvp in projectGraph.Where(p => p.Key.StartsWith("src/", StringComparison.Ordinal)))
|
|
{
|
|
kvp.Value.Any(dep => dep.StartsWith("tests/", StringComparison.Ordinal))
|
|
.ShouldBeFalse($"{kvp.Key} must not reference test projects.");
|
|
}
|
|
|
|
HasCycle(projectGraph)
|
|
.ShouldBeFalse("Project references must remain acyclic.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes HighLevelCollectionApi_ShouldNotExpandRawBsonReaderWriterSurface.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HighLevelCollectionApi_ShouldNotExpandRawBsonReaderWriterSurface()
|
|
{
|
|
var lowLevelTypes = new[] { typeof(BsonSpanReader), typeof(BsonSpanWriter) };
|
|
|
|
var collectionOffenders = typeof(DocumentCollection<,>)
|
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
|
.Where(m => lowLevelTypes.Any(t => MethodUsesType(m, t)))
|
|
.Select(m => m.Name)
|
|
.Distinct()
|
|
.OrderBy(n => n)
|
|
.ToArray();
|
|
|
|
collectionOffenders.ShouldBeEmpty();
|
|
|
|
var dbContextOffenders = typeof(DocumentDbContext)
|
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
|
.Where(m => lowLevelTypes.Any(t => MethodUsesType(m, t)))
|
|
.Select(m => m.Name)
|
|
.Distinct()
|
|
.ToArray();
|
|
|
|
dbContextOffenders.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes CollectionAndIndexOrchestration_ShouldUseStoragePortInternally.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CollectionAndIndexOrchestration_ShouldUseStoragePortInternally()
|
|
{
|
|
var targetTypes = new[]
|
|
{
|
|
typeof(DocumentCollection<>),
|
|
typeof(DocumentCollection<,>),
|
|
typeof(BTreeIndex),
|
|
typeof(CollectionIndexManager<,>),
|
|
typeof(CollectionSecondaryIndex<,>),
|
|
typeof(VectorSearchIndex),
|
|
};
|
|
|
|
var fieldOffenders = targetTypes
|
|
.SelectMany(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
|
|
.Where(f => f.FieldType == typeof(StorageEngine))
|
|
.Select(f => $"{t.Name}.{f.Name}"))
|
|
.OrderBy(v => v)
|
|
.ToArray();
|
|
|
|
fieldOffenders.ShouldBeEmpty("Collection/index orchestration should hold IStorageEngine instead of concrete StorageEngine.");
|
|
}
|
|
|
|
private static Dictionary<string, List<string>> LoadSolutionProjectGraph(string repoRoot)
|
|
{
|
|
var solutionPath = Path.Combine(repoRoot, "CBDD.slnx");
|
|
var solutionDoc = XDocument.Load(solutionPath);
|
|
|
|
var projects = solutionDoc
|
|
.Descendants()
|
|
.Where(e => e.Name.LocalName == "Project")
|
|
.Select(e => e.Attribute("Path")?.Value)
|
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
|
.Select(p => NormalizePath(p!))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
var graph = projects.ToDictionary(
|
|
p => p,
|
|
_ => new List<string>(),
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (var project in projects)
|
|
{
|
|
var projectFile = Path.Combine(repoRoot, project);
|
|
var projectDoc = XDocument.Load(projectFile);
|
|
var projectDir = Path.GetDirectoryName(projectFile)!;
|
|
|
|
var refs = projectDoc
|
|
.Descendants()
|
|
.Where(e => e.Name.LocalName == "ProjectReference")
|
|
.Select(e => e.Attribute("Include")?.Value)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Select(v => v!.Replace('\\', '/'))
|
|
.Select(v => NormalizePath(Path.GetRelativePath(repoRoot, Path.GetFullPath(Path.Combine(projectDir, v)))))
|
|
.Where(projects.Contains)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.OrderBy(v => v, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
graph[project] = refs;
|
|
}
|
|
|
|
return graph;
|
|
}
|
|
|
|
private static bool HasCycle(Dictionary<string, List<string>> graph)
|
|
{
|
|
var state = graph.Keys.ToDictionary(k => k, _ => 0, StringComparer.Ordinal);
|
|
|
|
foreach (var node in graph.Keys)
|
|
{
|
|
if (state[node] == 0 && Visit(node))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
|
|
bool Visit(string node)
|
|
{
|
|
state[node] = 1; // visiting
|
|
foreach (var dep in graph[node])
|
|
{
|
|
if (state[dep] == 1)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (state[dep] == 0 && Visit(dep))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
state[node] = 2; // visited
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool MethodUsesType(MethodInfo method, Type forbidden)
|
|
{
|
|
if (TypeContains(method.ReturnType, forbidden))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return method.GetParameters().Any(p => TypeContains(p.ParameterType, forbidden));
|
|
}
|
|
|
|
private static bool TypeContains(Type inspected, Type forbidden)
|
|
{
|
|
if (inspected == forbidden)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (inspected.HasElementType && inspected.GetElementType() is { } elementType && TypeContains(elementType, forbidden))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!inspected.IsGenericType)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return inspected.GetGenericArguments().Any(t => TypeContains(t, forbidden));
|
|
}
|
|
|
|
private static string FindRepositoryRoot()
|
|
{
|
|
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (current != null)
|
|
{
|
|
var solutionPath = Path.Combine(current.FullName, "CBDD.slnx");
|
|
if (File.Exists(solutionPath))
|
|
{
|
|
return current.FullName;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
throw new InvalidOperationException("Unable to find repository root containing CBDD.slnx.");
|
|
}
|
|
|
|
private static string NormalizePath(string path)
|
|
=> path.Replace('\\', '/');
|
|
}
|