Skip to content

Commit

Permalink
Merge pull request #2232 from andy840119/add-arch-net
Browse files Browse the repository at this point in the history
Add the architecture test for this project.
  • Loading branch information
andy840119 authored May 19, 2024
2 parents 32a76e9 + 5d2fd96 commit 88a05ee
Show file tree
Hide file tree
Showing 38 changed files with 688 additions and 43 deletions.
78 changes: 78 additions & 0 deletions osu.Game.Rulesets.Karaoke.Architectures/BaseTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Linq;
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using NUnit.Framework;

namespace osu.Game.Rulesets.Karaoke.Architectures;

public abstract class BaseTest
{
private Project.ProjectAttribute? executeProject;

[SetUp]
public void SetUp()
{
executeProject = null;
}

#region Utility

protected Project.ProjectAttribute GetExecuteProject()
{
return executeProject ??= MethodUtils.GetExecuteProject();
}

protected Architecture GetProjectArchitecture(params Project.ProjectAttribute[] extraProjects)
{
// trying to get the callstack with test attribute
var rootObject = GetExecuteProject().RootObjectType;
var assembly = rootObject.Assembly;

// note:
// 1. only load the test assembly because loading too much assembly will cause the test to be slow.
// 2. should not filter the namespace in here because it will cause inner class or inherit class cannot be found.
return new ArchLoader()
.LoadAssembly(assembly)
.LoadAssemblies(extraProjects.Select(x => x.RootObjectType.Assembly).ToArray())
.Build();
}

protected void Assertion(Action assert)
{
// check the execute project type
var executeType = GetExecuteProject().ExecuteType;

switch (executeType)
{
case Project.ExecuteType.Check:
{
assert();
break;
}

case Project.ExecuteType.Report:
{
Assert.Multiple(() =>
{
assert();

int totalCount = TestContext.CurrentContext.AssertCount;
int failedCount = TestContext.CurrentContext.Result.Assertions.Count();
Console.WriteLine("=================================");
Console.WriteLine($"There are {failedCount} failed in {totalCount} test step.");
});

break;
}

default:
throw new ArgumentOutOfRangeException();
}
}

#endregion
}
60 changes: 60 additions & 0 deletions osu.Game.Rulesets.Karaoke.Architectures/Edit/Checks/TestCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Linq;
using ArchUnitNET.Domain.Extensions;
using NUnit.Framework;
using osu.Game.Rulesets.Edit.Checks.Components;

namespace osu.Game.Rulesets.Karaoke.Architectures.Edit.Checks;

public class TestCheck : BaseTest
{
[Test]
[Project.Karaoke(true)]
public void CheckCheckClassNamingAndInherit()
{
var architecture = GetProjectArchitecture();

var baseIssue = architecture.GetClassOfType(typeof(Issue));
var issues = architecture.Classes.Where(x => x.Namespace.RelativeNameMatches(GetExecuteProject(), "Edit.Checks.Issues")).ToArray();

var checkOrIssueTemplate = architecture.Classes.Where(x => x.Namespace.RelativeNameMatches(GetExecuteProject(), "Edit.Checks")).ToArray();

var baseCheck = architecture.GetInterfaceOfType(typeof(ICheck));
var checks = checkOrIssueTemplate.Where(x => x.IsNested == false).ToArray();

var baseIssueTemplate = architecture.GetClassOfType(typeof(IssueTemplate));
var issueTemplates = checkOrIssueTemplate.Where(x => x.IsNested).ToArray();

Assertion(() =>
{
// issues.
Assert.NotZero(issues.Length, $"{nameof(Issue)} amount is weird.");

foreach (var checkClass in issues)
{
Assert.True(checkClass.InheritedClasses.Contains(baseIssue), $"Class inherit is invalid: {checkClass}");
Assert.True(checkClass.NameEndsWith("Issue"), $"Class name is invalid: {checkClass}");
}

// checks
Assert.NotZero(checks.Length, $"{nameof(ICheck)} amount is weird.");

foreach (var check in checks)
{
Assert.True(check.ImplementsInterface(baseCheck), $"Class inherit is invalid: {check}");
Assert.True(check.NameStartsWith("Check"), $"Class name is invalid: {check}");
}

// issue templates.
Assert.NotZero(issueTemplates.Length, $"{nameof(IssueTemplate)} amount is weird");

foreach (var checkClass in issueTemplates)
{
Assert.True(checkClass.InheritedClasses.Contains(baseIssueTemplate), $"Class inherit is invalid: {checkClass}");
Assert.True(checkClass.NameStartsWith("IssueTemplate"), $"Class name is invalid: {checkClass}");
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Linq;
using ArchUnitNET.Domain;
using ArchUnitNET.Domain.Extensions;
using NUnit.Framework;
using osu.Game.Rulesets.Karaoke.Tests.Editor.Checks;

namespace osu.Game.Rulesets.Karaoke.Architectures.Edit.Checks;

public class TestCheckTest : BaseTest
{
[Test]
[Project.KaraokeTest(true)]
public void CheckShouldContainsTest()
{
var architecture = GetProjectArchitecture(new Project.KaraokeAttribute());

var baseCheckTest = architecture.GetClassOfType(typeof(BaseCheckTest<>));
var allChecks = architecture.Classes
.Where(x => x.Namespace.RelativeNameMatches(new Project.KaraokeAttribute(), "Edit.Checks"))
.Where(x => x.IsNested == false && x.IsAbstract == false)
.ToArray();
var allCheckTests = architecture.Classes.Where(x => x.InheritedClasses.Contains(baseCheckTest)).ToArray();

Assertion(() =>
{
Assert.NotZero(allChecks.Length, "No check found");

foreach (var check in allChecks)
{
// need to make sure that all checks have a test class.
var matchedTest = allCheckTests.FirstOrDefault(x => x.NameContains(check.Name));
Assert.IsTrue(matchedTest != null, $"Check {check} should have a test class.");

// need to make sure that all issue template should be tested.
var innerIssueTemplates = check.GetInnerClasses();

foreach (var issueTemplate in innerIssueTemplates)
{
Assert.IsTrue(check.GetTypeDependencies().Contains(issueTemplate), $"Seems {issueTemplate} is not tested.");
}
}
});
}

[Test]
[Project.KaraokeTest(true)]
public void CheckTestMethod()
{
var architecture = GetProjectArchitecture();
var baseCheckTest = architecture.GetClassOfType(typeof(BaseCheckTest<>));

var assertOkMethod = baseCheckTest.GetMethodMembersContainsName("AssertOk").FirstOrDefault();
var assertNotOkMethod = baseCheckTest.GetMethodMembersContainsName("AssertNotOk").FirstOrDefault();

var allCheckTests = architecture.Classes.Where(x => x.InheritedClasses.Contains(baseCheckTest) && x.IsAbstract == false).ToArray();

Assertion(() =>
{
Assert.NotNull(assertOkMethod, "AssertOk method not found");
Assert.NotNull(assertNotOkMethod, "AssertNotOk method not found");

Assert.NotZero(allCheckTests.Length, "No check test found");

foreach (var checkTest in allCheckTests)
{
var testMethods = checkTest.GetAllTestMembers(architecture).ToArray();
Assert.NotZero(testMethods.Length, $"No test method in the {checkTest}");

foreach (var testMethod in testMethods)
{
Assert.IsTrue(isTestMethod(testMethod), $"Test method {testMethod} should call {assertOkMethod} or {assertNotOkMethod} method.");
}
}

return;

static bool isTestMethod(IMember testMethod)
{
var calledMethods = testMethod.GetCalledMethods().ToArray();
return calledMethods.Any(x => x.NameStartsWith("AssertOk") || x.NameStartsWith("AssertNotOk"));
}
});
}
}
96 changes: 96 additions & 0 deletions osu.Game.Rulesets.Karaoke.Architectures/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ArchUnitNET.Domain;
using ArchUnitNET.Domain.Extensions;
using NUnit.Framework;
using Attribute = ArchUnitNET.Domain.Attribute;

namespace osu.Game.Rulesets.Karaoke.Architectures;

public static class Extensions
{
#region Class

public static IEnumerable<Class> GetInnerClasses(this Class @class)
{
// do the same things as type.GetNestedTypes();
// see: https://learn.microsoft.com/en-us/dotnet/api/system.type.getnestedtypes?view=net-8.0&redirectedfrom=MSDN#overloads
// note: cannot use @class.GetNestedTypes(); because it will return the nested class in the same file.
return @class.GetTypeDependencies().Where(x => x.IsNested).OfType<Class>().Distinct();
}

public static IEnumerable<IMember> GetAllTestMembers(this Class @class, Architecture architecture)
{
var testAttribute = architecture.GetAttributeOfType(typeof(TestAttribute));
var testCaseAttribute = architecture.GetAttributeOfType(typeof(TestCaseAttribute));
return @class.MembersIncludingInherited.Where(x => x.Attributes.Contains(testAttribute) || x.Attributes.Contains(testCaseAttribute));
}

public static bool HasAttributeInSelfOrChild(this Class @class, Attribute attribute)
{
return @class.Attributes.Contains(attribute) || @class.InheritedClasses.Any(c => c.Attributes.Contains(attribute));
}

#endregion

#region Name

public static bool RelativeNameStartsWith(
this IHasName cls,
Project.ProjectAttribute project,
string pattern,
StringComparison stringComparison = StringComparison.CurrentCulture)
{
string relativeNamespace = getRelativeNamespace(project, cls.FullName);
return relativeNamespace.StartsWith(pattern, stringComparison);
}

public static bool RelativeNameMatches(this IHasName cls,
Project.ProjectAttribute project,
string pattern,
bool useRegularExpressions = false)
{
string relativeNamespace = getRelativeNamespace(project, cls.FullName);

if (!useRegularExpressions)
return string.Equals(relativeNamespace, pattern, StringComparison.OrdinalIgnoreCase);

return Regex.IsMatch(relativeNamespace, pattern);
}

private static string getRelativeNamespace(Project.ProjectAttribute project, string fullNamespace)
{
string? rootObjectNamespace = project.RootObjectType.Namespace;
if (rootObjectNamespace == null)
throw new NotSupportedException("Root object namespace should not be null.");

if (!fullNamespace.StartsWith(rootObjectNamespace, StringComparison.Ordinal))
throw new NotSupportedException("The namespace of the class is not in the root object namespace.");

// remove the start namespace with dot.
return fullNamespace == rootObjectNamespace
? string.Empty
: fullNamespace[$"{rootObjectNamespace}.".Length..];
}

#endregion

#region Type

public static IEnumerable<MethodMember> GetMethodMembersContainsName(this IType type, string name)
{
return type.GetMethodMembers().WhereNameContains<MethodMember>(name);
}

public static IEnumerable<TType> WhereNameContains<TType>(this IEnumerable<TType> source, string name) where TType : IHasName
{
return source.Where(hasName => hasName.Name.Contains(name));
}

#endregion
}
53 changes: 53 additions & 0 deletions osu.Game.Rulesets.Karaoke.Architectures/MethodUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using NUnit.Framework;

namespace osu.Game.Rulesets.Karaoke.Architectures;

public class MethodUtils
{
public static Project.ProjectAttribute GetExecuteProject()
{
// trying to get the callstack with test attribute
var stackTrace = new StackTrace();
var frames = stackTrace.GetFrames();

foreach (var frame in frames)
{
var method = frame.GetMethod();
if (method == null)
continue;

var attributes = method.CustomAttributes;
if (attributes.All(x => x.AttributeType != typeof(TestAttribute)))
continue;

return getDefaultAttributeByMethod(method);
}

throw new InvalidOperationException("Test method is not in the callstack.");

static Project.ProjectAttribute getDefaultAttributeByMethod(MethodBase method)
{
var projects = method.CustomAttributes
.Where(x => x.AttributeType.BaseType == typeof(Project.ProjectAttribute))
.Select(x =>
{
object?[] constructorParams = x.ConstructorArguments.Select(args => args.Value).ToArray();
object? instance = Activator.CreateInstance(x.AttributeType, constructorParams);

if (instance is not Project.ProjectAttribute propertyAttribute)
throw new InvalidOperationException("The attribute is not found.");

return propertyAttribute;
});

return projects.Single(x => x.Execute);
}
}
}
14 changes: 14 additions & 0 deletions osu.Game.Rulesets.Karaoke.Architectures/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) andy840119 <[email protected]>. Licensed under the GPL Licence.
// See the LICENCE file in the repository root for full licence text.

using System;

namespace osu.Game.Rulesets.Karaoke.Architectures;

public static class Program
{
public static void Main()
{
Console.WriteLine("This is the project for checking the architecture is designed as expected!");
}
}
Loading

0 comments on commit 88a05ee

Please sign in to comment.