diff --git a/.github/workflows/CSharpTest.yml b/.github/workflows/CSharpTest.yml
index b73e31615..d05dc2f8b 100644
--- a/.github/workflows/CSharpTest.yml
+++ b/.github/workflows/CSharpTest.yml
@@ -40,7 +40,15 @@ jobs:
- name: Test if not to send
if: ${{ matrix.os != 'windows-latest' }}
run: |
- dotnet test "Sources/Tests/UnitTests" -c Release -l GitHubActions /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
+ dotnet test "Sources/Tests/UnitTests" -c Release -l GitHubActions -l "trx;LogFileName=results.trx" --results-directory "Sources/Tests/UnitTests/TestResults" /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
+
+ # Where the suite's time goes, by the Area trait every test class carries, written to the
+ # job's summary. One leg is enough to see the breakdown, and running the tool builds the
+ # library once more. https://github.com/asc-community/AngouriMath/issues/1183
+ - name: 'Time by area'
+ if: ${{ always() && matrix.os == 'ubuntu-latest' }}
+ run: dotnet run AreaTimes
+ working-directory: Sources/Utils/Utils
- name: Test if to send
if: ${{ matrix.os == 'windows-latest' }}
diff --git a/Sources/Tests/UnitTests/Algebra/EquationWithoutTheUnknownTest.cs b/Sources/Tests/UnitTests/Algebra/EquationWithoutTheUnknownTest.cs
index c9812353f..7a42741be 100644
--- a/Sources/Tests/UnitTests/Algebra/EquationWithoutTheUnknownTest.cs
+++ b/Sources/Tests/UnitTests/Algebra/EquationWithoutTheUnknownTest.cs
@@ -19,6 +19,7 @@ namespace AngouriMath.Tests.Algebra
/// x satisfies it, and every x does whenever the two sides happen to be
/// equal. https://github.com/asc-community/AngouriMath/issues/278
///
+ [Trait("Area", "Algebra")]
public sealed class EquationWithoutTheUnknownTest
{
///
diff --git a/Sources/Tests/UnitTests/Algebra/PythagoreanEquationRewriteTest.cs b/Sources/Tests/UnitTests/Algebra/PythagoreanEquationRewriteTest.cs
index 19e2d53cf..9c5e5fe24 100644
--- a/Sources/Tests/UnitTests/Algebra/PythagoreanEquationRewriteTest.cs
+++ b/Sources/Tests/UnitTests/Algebra/PythagoreanEquationRewriteTest.cs
@@ -26,6 +26,7 @@ namespace AngouriMath.Tests.Algebra
/// sin(x)^2 + sin(x) = 0 has always answered in arcsines. The mixed form simply
/// never became one.
///
+ [Trait("Area", "Algebra")]
public sealed class PythagoreanEquationRewriteTest
{
///
diff --git a/Sources/Tests/UnitTests/Calculus/BinomialIdentitiesTest.cs b/Sources/Tests/UnitTests/Calculus/BinomialIdentitiesTest.cs
index 5f8ef455b..a1df2e6e4 100644
--- a/Sources/Tests/UnitTests/Calculus/BinomialIdentitiesTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/BinomialIdentitiesTest.cs
@@ -21,6 +21,7 @@ namespace AngouriMath.Tests.Calculus
/// second form), the parallel summation (Prob 8.9.19) and Vandermonde along the upper
/// indices (Prob 8.9.18).
///
+ [Trait("Area", "Calculus")]
public sealed class BinomialIdentitiesTest
{
/// The closed form against the sum written out, at small bounds and one past the expansion's hundred terms.
diff --git a/Sources/Tests/UnitTests/Calculus/BinomialSumTest.cs b/Sources/Tests/UnitTests/Calculus/BinomialSumTest.cs
index 6234d2945..486dda342 100644
--- a/Sources/Tests/UnitTests/Calculus/BinomialSumTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/BinomialSumTest.cs
@@ -17,6 +17,7 @@ namespace AngouriMath.Tests.Calculus
/// https://github.com/asc-community/AngouriMath/issues/1212, question II.3: a binomial sum
/// with a power or a trigonometric weight is the binomial theorem read backwards.
///
+ [Trait("Area", "Calculus")]
public sealed class BinomialSumTest
{
private const string Binomial = "N! / (k! * (N - k)!)";
diff --git a/Sources/Tests/UnitTests/Calculus/BreakpointIntegrationTest.cs b/Sources/Tests/UnitTests/Calculus/BreakpointIntegrationTest.cs
index e4d1d93b0..3f2d0bcb2 100644
--- a/Sources/Tests/UnitTests/Calculus/BreakpointIntegrationTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/BreakpointIntegrationTest.cs
@@ -18,6 +18,7 @@ namespace AngouriMath.Tests.Calculus
/// through an antiderivative that holds the case or the step constant across them. Question
/// I.2 of https://github.com/asc-community/AngouriMath/issues/1212, and the review of #1215.
///
+ [Trait("Area", "Calculus")]
public sealed class BreakpointIntegrationTest
{
// ---- a piecewise: finitely many breakpoints ---------------------------------------------
diff --git a/Sources/Tests/UnitTests/Calculus/ConstantRateIntegrandTest.cs b/Sources/Tests/UnitTests/Calculus/ConstantRateIntegrandTest.cs
index b35c7f614..8ffa439d7 100644
--- a/Sources/Tests/UnitTests/Calculus/ConstantRateIntegrandTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/ConstantRateIntegrandTest.cs
@@ -23,6 +23,7 @@ namespace AngouriMath.Tests.Calculus
/// gathering of two powers of one base turns e^x * e^(-x) into exactly this
/// shape -- but the defect predates it and is reachable by writing the exponent out.
///
+ [Trait("Area", "Calculus")]
public sealed class ConstantRateIntegrandTest
{
///
diff --git a/Sources/Tests/UnitTests/Calculus/DivergentSeriesTest.cs b/Sources/Tests/UnitTests/Calculus/DivergentSeriesTest.cs
index 322eacf6a..18b68824a 100644
--- a/Sources/Tests/UnitTests/Calculus/DivergentSeriesTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/DivergentSeriesTest.cs
@@ -18,6 +18,7 @@ namespace AngouriMath.Tests.Calculus
/// nth-term test, asked for on the review of
/// https://github.com/asc-community/AngouriMath/pull/1218.
///
+ [Trait("Area", "Calculus")]
public sealed class DivergentSeriesTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Calculus/ExponentialSeriesTest.cs b/Sources/Tests/UnitTests/Calculus/ExponentialSeriesTest.cs
index 38c6cee83..e1cb1b31b 100644
--- a/Sources/Tests/UnitTests/Calculus/ExponentialSeriesTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/ExponentialSeriesTest.cs
@@ -17,6 +17,7 @@ namespace AngouriMath.Tests.Calculus
/// https://github.com/asc-community/AngouriMath/issues/1212, question I.1: a summation to
/// +oo of a polynomial times a power over a factorial of the index is a closed form in e.
///
+ [Trait("Area", "Calculus")]
public sealed class ExponentialSeriesTest
{
private static void AgreesTo(string expected, Entity actual, int digits = 25)
diff --git a/Sources/Tests/UnitTests/Calculus/ExtremumTest.cs b/Sources/Tests/UnitTests/Calculus/ExtremumTest.cs
index 3baab8282..57748add6 100644
--- a/Sources/Tests/UnitTests/Calculus/ExtremumTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/ExtremumTest.cs
@@ -17,6 +17,7 @@ namespace AngouriMath.Tests.Calculus
/// max(f(t), t in S), min, argmax and argmin: the extremum of an
/// expression over a set, as binders. https://github.com/asc-community/AngouriMath/issues/1212
///
+ [Trait("Area", "Calculus")]
public sealed class ExtremumTest
{
// The sheet's question I.6: the area of a projectile's flight is largest at pi/3.
diff --git a/Sources/Tests/UnitTests/Calculus/GeometricSeriesTest.cs b/Sources/Tests/UnitTests/Calculus/GeometricSeriesTest.cs
index 6b50ba1d8..8030eac81 100644
--- a/Sources/Tests/UnitTests/Calculus/GeometricSeriesTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/GeometricSeriesTest.cs
@@ -17,6 +17,7 @@ namespace AngouriMath.Tests.Calculus
/// geometric series, to a bound or to +oo. Part of question I.2 of
/// https://github.com/asc-community/AngouriMath/issues/1212.
///
+ [Trait("Area", "Calculus")]
public sealed class GeometricSeriesTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Calculus/IntegralPiecewiseReducedTest.cs b/Sources/Tests/UnitTests/Calculus/IntegralPiecewiseReducedTest.cs
index 66388f75c..02a1c1959 100644
--- a/Sources/Tests/UnitTests/Calculus/IntegralPiecewiseReducedTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/IntegralPiecewiseReducedTest.cs
@@ -32,6 +32,7 @@ namespace AngouriMath.Tests.Calculus
/// it to: it inner-simplifies its *input* and returns its output untouched, while the
/// definite overload has always inner-simplified the antiderivative.
///
+ [Trait("Area", "Calculus")]
public sealed class IntegralPiecewiseReducedTest
{
///
diff --git a/Sources/Tests/UnitTests/Calculus/InverseHyperbolicSubstitutionTest.cs b/Sources/Tests/UnitTests/Calculus/InverseHyperbolicSubstitutionTest.cs
index c0fc4bb5a..04dd8fd63 100644
--- a/Sources/Tests/UnitTests/Calculus/InverseHyperbolicSubstitutionTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/InverseHyperbolicSubstitutionTest.cs
@@ -20,6 +20,7 @@ namespace AngouriMath.Tests.Calculus
/// of it, which the exponential rules read: x arsinh(a x) is u sinh(2u)/(2a^2).
/// #718
///
+ [Trait("Area", "Calculus")]
public sealed class InverseHyperbolicSubstitutionTest
{
private static void DifferentiatesBack(string integrand, double[] points)
diff --git a/Sources/Tests/UnitTests/Calculus/LinearityBeforeByPartsTest.cs b/Sources/Tests/UnitTests/Calculus/LinearityBeforeByPartsTest.cs
index 5bf333c2b..6ab3e1f65 100644
--- a/Sources/Tests/UnitTests/Calculus/LinearityBeforeByPartsTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/LinearityBeforeByPartsTest.cs
@@ -23,6 +23,7 @@ namespace AngouriMath.Tests.Calculus
/// Every answer is checked by differentiating it back and comparing at points: what
/// matters is that it is an antiderivative, not what form it is written in.
///
+ [Trait("Area", "Calculus")]
public sealed class LinearityBeforeByPartsTest
{
///
diff --git a/Sources/Tests/UnitTests/Calculus/ModulusPhaseTest.cs b/Sources/Tests/UnitTests/Calculus/ModulusPhaseTest.cs
index 74d6cf1b7..94581839a 100644
--- a/Sources/Tests/UnitTests/Calculus/ModulusPhaseTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/ModulusPhaseTest.cs
@@ -20,6 +20,7 @@ namespace AngouriMath.Tests.Calculus
/// with the wrong answer: a zero times a complex infinity evaluated to 0, a complex infinity
/// did not count as infinite, and the descent's unread 0 / abs(0) was returned as NaN.
///
+ [Trait("Area", "Calculus")]
public sealed class ModulusPhaseTest
{
private static readonly Variable x = MathS.Var("x");
diff --git a/Sources/Tests/UnitTests/Calculus/PolynomialGeometricSeriesTest.cs b/Sources/Tests/UnitTests/Calculus/PolynomialGeometricSeriesTest.cs
index a1145ecfb..8e16dcfe9 100644
--- a/Sources/Tests/UnitTests/Calculus/PolynomialGeometricSeriesTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/PolynomialGeometricSeriesTest.cs
@@ -19,6 +19,7 @@ namespace AngouriMath.Tests.Calculus
/// sum((-1)^(k - 1) k^2, k, 1, n) = (-1)^(n - 1) n (n + 1)/2, is the row that asked
/// for it.
///
+ [Trait("Area", "Calculus")]
public sealed class PolynomialGeometricSeriesTest
{
/// The closed form against the sum written out, at a bound past the expansion's hundred terms and at small ones.
diff --git a/Sources/Tests/UnitTests/Calculus/PowersOfOneBaseMergedTest.cs b/Sources/Tests/UnitTests/Calculus/PowersOfOneBaseMergedTest.cs
index c03abb9d6..a5563724f 100644
--- a/Sources/Tests/UnitTests/Calculus/PowersOfOneBaseMergedTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/PowersOfOneBaseMergedTest.cs
@@ -25,6 +25,7 @@ namespace AngouriMath.Tests.Calculus
/// not run PowerRules at all, so even the sibling case was missed.
/// https://github.com/asc-community/AngouriMath/issues/781
///
+ [Trait("Area", "Calculus")]
public sealed class PowersOfOneBaseMergedTest
{
/// Sample points, chosen to include negative x and to avoid the zeros of sin.
diff --git a/Sources/Tests/UnitTests/Calculus/SymbolicQuadraticCoefficientTest.cs b/Sources/Tests/UnitTests/Calculus/SymbolicQuadraticCoefficientTest.cs
index 2d896c1ad..e233f068f 100644
--- a/Sources/Tests/UnitTests/Calculus/SymbolicQuadraticCoefficientTest.cs
+++ b/Sources/Tests/UnitTests/Calculus/SymbolicQuadraticCoefficientTest.cs
@@ -34,6 +34,7 @@ namespace AngouriMath.Tests.Calculus
/// testing the simplifier's reach rather than the integrator's answer.
///
///
+ [Trait("Area", "Calculus")]
public sealed class SymbolicQuadraticCoefficientTest
{
private const double RelativeTolerance = 1e-8;
diff --git a/Sources/Tests/UnitTests/Common/AreaTraitTest.cs b/Sources/Tests/UnitTests/Common/AreaTraitTest.cs
new file mode 100644
index 000000000..5b751d688
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/AreaTraitTest.cs
@@ -0,0 +1,52 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System;
+using System.Linq;
+using System.Reflection;
+using Xunit;
+
+namespace AngouriMath.Tests.Common
+{
+ ///
+ /// Every test class says which area of the library it tests, with
+ /// [Trait("Area", …)], so that a run can be filtered to an area and its time broken
+ /// down by area. A taxonomy is worth what its completeness is worth, and nothing else
+ /// notices a class without one: there were 22 such files when this was asked for and 37 a
+ /// fortnight later.
+ /// https://github.com/asc-community/AngouriMath/issues/1183
+ ///
+ [Trait("Area", "Common")]
+ public sealed class AreaTraitTest
+ {
+ [Fact]
+ public void EveryTestClassHasAnArea()
+ {
+ var missing = typeof(AreaTraitTest).Assembly.GetTypes()
+ .Where(static type => !type.IsAbstract && TestMethods(type).Any())
+ .Where(static type => !HasArea(type) && !TestMethods(type).All(HasArea))
+ .Select(static type => type.FullName)
+ .OrderBy(static name => name, StringComparer.Ordinal)
+ .ToList();
+ Assert.True(missing.Count == 0,
+ "These test classes say nothing about which area they test; give each a [Trait(\"Area\", …)]:\n "
+ + string.Join("\n ", missing));
+ }
+
+ /// The methods xUnit runs as tests: a is a .
+ private static System.Collections.Generic.IEnumerable TestMethods(Type type)
+ => type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
+ .Where(static method => method.IsDefined(typeof(FactAttribute), inherit: true));
+
+ /// Declared on the member itself or, for a class, on a class it derives from.
+ private static bool HasArea(MemberInfo member)
+ => member.GetCustomAttributesData().Any(static attribute =>
+ attribute.AttributeType == typeof(TraitAttribute)
+ && attribute.ConstructorArguments is [{ Value: "Area" }, _])
+ || member is Type { BaseType: { } baseType } && baseType != typeof(object) && HasArea(baseType);
+ }
+}
diff --git a/Sources/Tests/UnitTests/Common/IntervalSimplificationTest.cs b/Sources/Tests/UnitTests/Common/IntervalSimplificationTest.cs
index 4f1dcc8ab..03a5c7100 100644
--- a/Sources/Tests/UnitTests/Common/IntervalSimplificationTest.cs
+++ b/Sources/Tests/UnitTests/Common/IntervalSimplificationTest.cs
@@ -21,6 +21,7 @@ namespace AngouriMath.Tests.Common
/// intersection of my own choosing was tested instead, and the maintainer said so on the
/// issue. This pins the reporter's expression, character for character.
///
+ [Trait("Area", "Common")]
public sealed class IntervalSimplificationTest
{
///
diff --git a/Sources/Tests/UnitTests/Common/LogarithmOfAPerfectPowerTest.cs b/Sources/Tests/UnitTests/Common/LogarithmOfAPerfectPowerTest.cs
index 59f344a2b..848e93ada 100644
--- a/Sources/Tests/UnitTests/Common/LogarithmOfAPerfectPowerTest.cs
+++ b/Sources/Tests/UnitTests/Common/LogarithmOfAPerfectPowerTest.cs
@@ -18,6 +18,7 @@ namespace AngouriMath.Tests.Common
/// a logarithm of a rational literal that is a perfect power takes the exponent out, which
/// is longer on its own and is what lets two logarithms of related literals collect.
///
+ [Trait("Area", "Common")]
public sealed class LogarithmOfAPerfectPowerTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Common/PiecewiseReachabilityTest.cs b/Sources/Tests/UnitTests/Common/PiecewiseReachabilityTest.cs
index bd2bfabd9..a6ece6e7b 100644
--- a/Sources/Tests/UnitTests/Common/PiecewiseReachabilityTest.cs
+++ b/Sources/Tests/UnitTests/Common/PiecewiseReachabilityTest.cs
@@ -33,6 +33,7 @@ namespace AngouriMath.Tests.Common
/// change which value is taken.
///
///
+ [Trait("Area", "Common")]
public sealed class PiecewiseReachabilityTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Convenience/SpellingsAtRiskTest.cs b/Sources/Tests/UnitTests/Convenience/SpellingsAtRiskTest.cs
index ef8105774..65f12c78e 100644
--- a/Sources/Tests/UnitTests/Convenience/SpellingsAtRiskTest.cs
+++ b/Sources/Tests/UnitTests/Convenience/SpellingsAtRiskTest.cs
@@ -35,6 +35,7 @@ namespace AngouriMath.Tests.Convenience
/// with it.
///
///
+ [Trait("Area", "Convenience")]
public sealed class SpellingsAtRiskTest
{
// `|` is an alias for `or`, so a divisibility statement is read as a disjunction.
diff --git a/Sources/Tests/UnitTests/Core/CardinalityTest.cs b/Sources/Tests/UnitTests/Core/CardinalityTest.cs
index 4da7f89e3..ce3389df8 100644
--- a/Sources/Tests/UnitTests/Core/CardinalityTest.cs
+++ b/Sources/Tests/UnitTests/Core/CardinalityTest.cs
@@ -16,6 +16,7 @@ namespace AngouriMath.Tests.Core
/// card(S): the number of elements of a set, as a node of its own.
/// https://github.com/asc-community/AngouriMath/issues/1212
///
+ [Trait("Area", "Core")]
public sealed class CardinalityTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/ContradictoryConjunctionTest.cs b/Sources/Tests/UnitTests/Core/ContradictoryConjunctionTest.cs
index a43d10250..5b062f427 100644
--- a/Sources/Tests/UnitTests/Core/ContradictoryConjunctionTest.cs
+++ b/Sources/Tests/UnitTests/Core/ContradictoryConjunctionTest.cs
@@ -21,6 +21,7 @@ namespace AngouriMath.Tests.Core
/// nine, a sum of such piecewises had 3^12 cases and took 8 GB.
/// #1414
///
+ [Trait("Area", "Core")]
public sealed class ContradictoryConjunctionTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/DividesTest.cs b/Sources/Tests/UnitTests/Core/DividesTest.cs
index ec5ab324c..36b88b664 100644
--- a/Sources/Tests/UnitTests/Core/DividesTest.cs
+++ b/Sources/Tests/UnitTests/Core/DividesTest.cs
@@ -17,6 +17,7 @@ namespace AngouriMath.Tests.Core
/// a divides b: the statement that b is a whole multiple of a, as a node
/// of its own. https://github.com/asc-community/AngouriMath/issues/1212
///
+ [Trait("Area", "Core")]
public sealed class DividesTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/FactorialCacheIsThreadSafeTest.cs b/Sources/Tests/UnitTests/Core/FactorialCacheIsThreadSafeTest.cs
index c1e960f5c..5744d6d80 100644
--- a/Sources/Tests/UnitTests/Core/FactorialCacheIsThreadSafeTest.cs
+++ b/Sources/Tests/UnitTests/Core/FactorialCacheIsThreadSafeTest.cs
@@ -42,6 +42,7 @@ namespace AngouriMath.Tests.Core
/// concurrent read and write, which is a property of the contract rather than of any run.
///
///
+ [Trait("Area", "Core")]
public sealed class FactorialCacheIsThreadSafeTest
{
/// Above anything another test is likely to have cached, so growth really runs.
diff --git a/Sources/Tests/UnitTests/Core/IntervalEvaluationTest.cs b/Sources/Tests/UnitTests/Core/IntervalEvaluationTest.cs
index 4e53787e4..d6db4cb54 100644
--- a/Sources/Tests/UnitTests/Core/IntervalEvaluationTest.cs
+++ b/Sources/Tests/UnitTests/Core/IntervalEvaluationTest.cs
@@ -18,6 +18,7 @@ namespace AngouriMath.Tests.Core
/// does not read, and compares in three outcomes.
/// #1019
///
+ [Trait("Area", "Core")]
public sealed class IntervalEvaluationTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/MatrixConcurrencyTest.cs b/Sources/Tests/UnitTests/Core/MatrixConcurrencyTest.cs
index 3512fe9fa..923cb7bc5 100644
--- a/Sources/Tests/UnitTests/Core/MatrixConcurrencyTest.cs
+++ b/Sources/Tests/UnitTests/Core/MatrixConcurrencyTest.cs
@@ -27,6 +27,7 @@ namespace AngouriMath.Tests.Core
/// returning a wrong entity rather than by throwing, so those assertions count disagreements
/// instead of stopping at the first one.
///
+ [Trait("Area", "Core")]
public sealed class MatrixConcurrencyTest
{
private const int Matrices = 40;
diff --git a/Sources/Tests/UnitTests/Core/SortKeyCacheTest.cs b/Sources/Tests/UnitTests/Core/SortKeyCacheTest.cs
index 5d0d34769..1e1866d8b 100644
--- a/Sources/Tests/UnitTests/Core/SortKeyCacheTest.cs
+++ b/Sources/Tests/UnitTests/Core/SortKeyCacheTest.cs
@@ -21,6 +21,7 @@ namespace AngouriMath.Tests.Core
/// with spells its own key rather than inheriting the original's, and asking from
/// several threads at once gives every thread the same string.
///
+ [Trait("Area", "Core")]
public sealed class SortKeyCacheTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/Transformations/EBindingsTest.cs b/Sources/Tests/UnitTests/Core/Transformations/EBindingsTest.cs
index 232a65519..4c5da0c70 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/EBindingsTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/EBindingsTest.cs
@@ -10,6 +10,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class EBindingsTest
{
[Fact]
diff --git a/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs b/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
index f9f8226ef..e2a4be316 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
@@ -14,6 +14,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class EGraphTest
{
[Fact]
diff --git a/Sources/Tests/UnitTests/Core/Transformations/MatchPatternEMatchTest.cs b/Sources/Tests/UnitTests/Core/Transformations/MatchPatternEMatchTest.cs
index afb63cd55..eae67da47 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/MatchPatternEMatchTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/MatchPatternEMatchTest.cs
@@ -18,6 +18,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class MatchPatternEMatchTest
{
private static readonly Func Cost = AngouriMath.Core.CostModel.Default.Cost;
diff --git a/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleGrowthTest.cs b/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleGrowthTest.cs
index ad5d30710..0301cd0cd 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleGrowthTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleGrowthTest.cs
@@ -11,6 +11,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class MatchedRuleGrowthTest
{
[Fact]
diff --git a/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleTryEMatchApplyTest.cs b/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleTryEMatchApplyTest.cs
index 94a61607e..db23c55ac 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleTryEMatchApplyTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/MatchedRuleTryEMatchApplyTest.cs
@@ -13,6 +13,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class MatchedRuleTryEMatchApplyTest
{
private static readonly System.Func Cost = AngouriMath.Core.CostModel.Default.Cost;
diff --git a/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAllTest.cs b/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAllTest.cs
index 81df23c69..ddf619dcb 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAllTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAllTest.cs
@@ -11,6 +11,7 @@
namespace AngouriMath.Tests.Core.Transformations
{
+ [Trait("Area", "Core")]
public sealed class MatchedRulesAllTest
{
[Fact]
diff --git a/Sources/Tests/UnitTests/Core/UnknownDomainIsNotABugTest.cs b/Sources/Tests/UnitTests/Core/UnknownDomainIsNotABugTest.cs
index 9dde50645..b0ca26248 100644
--- a/Sources/Tests/UnitTests/Core/UnknownDomainIsNotABugTest.cs
+++ b/Sources/Tests/UnitTests/Core/UnknownDomainIsNotABugTest.cs
@@ -17,6 +17,7 @@ namespace UnitTests.Core
/// this library does not have was answered with AngouriBugException, which asks them to
/// report their own input to the issue tracker.
///
+ [Trait("Area", "Core")]
public sealed class UnknownDomainIsNotABugTest
{
[Theory]
diff --git a/Sources/Tests/UnitTests/Core/UnreachablePiecewiseCaseTest.cs b/Sources/Tests/UnitTests/Core/UnreachablePiecewiseCaseTest.cs
index 2e2a40916..d2d7f2537 100644
--- a/Sources/Tests/UnitTests/Core/UnreachablePiecewiseCaseTest.cs
+++ b/Sources/Tests/UnitTests/Core/UnreachablePiecewiseCaseTest.cs
@@ -22,6 +22,7 @@ namespace AngouriMath.Tests.Core
/// make it safe: dropping a case that was reachable would shorten the answer and change it,
/// and a test that only counted cases could not tell the two apart.
///
+ [Trait("Area", "Core")]
public sealed class UnreachablePiecewiseCaseTest
{
private static int CaseCount(Entity piecewise)
diff --git a/Sources/Tests/UnitTests/PatternsTest/RationalizeDenominatorTest.cs b/Sources/Tests/UnitTests/PatternsTest/RationalizeDenominatorTest.cs
index 822fc19aa..c29702a25 100644
--- a/Sources/Tests/UnitTests/PatternsTest/RationalizeDenominatorTest.cs
+++ b/Sources/Tests/UnitTests/PatternsTest/RationalizeDenominatorTest.cs
@@ -26,6 +26,7 @@ namespace AngouriMath.Tests.PatternsTest
/// the binomial case rather than introducing one, which is the half of #205 that does
/// not need a maintainer's ruling.
///
+ [Trait("Area", "PatternsTest")]
public sealed class RationalizeDenominatorTest
{
private static bool IsSurd(Entity node)
diff --git a/Sources/Utils/Utils/AreaTimes.cs b/Sources/Utils/Utils/AreaTimes.cs
new file mode 100644
index 000000000..312e1954e
--- /dev/null
+++ b/Sources/Utils/Utils/AreaTimes.cs
@@ -0,0 +1,131 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Xml.Linq;
+
+namespace Utils
+{
+ ///
+ /// Where a run of the unit tests spends its time, by area: the [Trait("Area", …)] on
+ /// each test class, summed over the run's results.
+ ///
+ ///
+ ///
+ /// Run with dotnet run AreaTimes from Sources/Utils/Utils, after
+ /// dotnet test Sources/Tests/UnitTests -c Release --logger "trx;LogFileName=results.trx".
+ /// It reads the newest .trx under the test project's TestResults, or the file
+ /// AREA_TIMES_TRX names, and the test assembly that file names; it writes a table of
+ /// the areas, how many tests each ran and how long they took. Where
+ /// GITHUB_STEP_SUMMARY is set the table goes there too, which is how the C# Test job
+ /// shows it.
+ ///
+ ///
+ /// The area is read from the test assembly rather than from the results file, which names a
+ /// test's class and not its traits. A class with no area is counted as (none), so the
+ /// table adds up to the whole run; AreaTraitTest is what keeps that row empty.
+ ///
+ /// https://github.com/asc-community/AngouriMath/issues/1183
+ ///
+ public static class AreaTimes
+ {
+ private static readonly XNamespace Trx = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010";
+
+ public static void Do()
+ {
+ var path = Environment.GetEnvironmentVariable("AREA_TIMES_TRX") ?? Newest();
+ var results = XDocument.Load(path);
+
+ var methods = results.Descendants(Trx + "UnitTest")
+ .Select(test => (Id: (string?)test.Attribute("id"), Method: test.Element(Trx + "TestMethod")))
+ .Where(test => test.Id is not null && test.Method is not null)
+ .Select(test => (Id: test.Id!, Method: test.Method!))
+ .ToList();
+ var codeBase = methods.Select(test => (string?)test.Method.Attribute("codeBase")).FirstOrDefault(p => p is not null)
+ ?? throw new InvalidOperationException($"{path} names no test assembly");
+ var assembly = Assembly.LoadFrom(codeBase);
+ var areaOfClass = new Dictionary(StringComparer.Ordinal);
+ var areaOfTest = methods.ToDictionary(
+ test => test.Id,
+ test => AreaOf((string?)test.Method.Attribute("className"), assembly, areaOfClass));
+
+ var totals = new Dictionary(StringComparer.Ordinal);
+ foreach (var result in results.Descendants(Trx + "UnitTestResult"))
+ {
+ var area = (string?)result.Attribute("testId") is { } id && areaOfTest.TryGetValue(id, out var known) ? known : "(none)";
+ var duration = (string?)result.Attribute("duration") is { } text
+ ? TimeSpan.Parse(text, CultureInfo.InvariantCulture)
+ : TimeSpan.Zero;
+ totals[area] = totals.TryGetValue(area, out var sum) ? (sum.Tests + 1, sum.Time + duration) : (1, duration);
+ }
+
+ var whole = totals.Values.Aggregate(TimeSpan.Zero, (time, row) => time + row.Time);
+ var table = new StringBuilder();
+ table.AppendLine("| Area | Tests | Seconds | Share |");
+ table.AppendLine("|---|--:|--:|--:|");
+ foreach (var (area, (tests, time)) in totals.OrderByDescending(row => row.Value.Time).Select(row => (row.Key, row.Value)))
+ table.AppendLine(string.Create(CultureInfo.InvariantCulture,
+ $"| {area} | {tests} | {time.TotalSeconds:F1} | {(whole.Ticks == 0 ? 0 : 100.0 * time.Ticks / whole.Ticks):F1}% |"));
+ table.AppendLine(string.Create(CultureInfo.InvariantCulture,
+ $"| **all** | {totals.Values.Sum(row => row.Tests)} | {whole.TotalSeconds:F1} | 100% |"));
+
+ // xUnit runs test classes in parallel, so the durations add up to more than the run took.
+ table.AppendLine();
+ table.AppendLine("Seconds are each test's own duration, added up; the classes run in parallel, so the total is more than the run's wall-clock time.");
+
+ Console.WriteLine(table);
+ if (Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY") is { Length: > 0 } summary)
+ File.AppendAllText(summary, "### Unit test time by area\n\n" + table + "\n");
+ }
+
+ /// The newest results file under the test project's TestResults.
+ private static string Newest()
+ {
+ var directory = Path.Combine(Program.GetPathIntoSources(), "Tests", "UnitTests", "TestResults");
+ return new DirectoryInfo(directory).EnumerateFiles("*.trx", SearchOption.AllDirectories)
+ .OrderByDescending(file => file.LastWriteTimeUtc)
+ .Select(file => file.FullName)
+ .FirstOrDefault()
+ ?? throw new FileNotFoundException($"no .trx under {directory}; run the tests with --logger trx first");
+ }
+
+ ///
+ /// The area a test class declares, read by name so that nothing here depends on xUnit;
+ /// the class's base classes are asked too, and (none) where none of them says.
+ ///
+ private static string AreaOf(string? className, Assembly assembly, Dictionary known)
+ {
+ if (className is null)
+ return "(none)";
+ if (known.TryGetValue(className, out var area))
+ return area;
+ area = "(none)";
+ for (var type = assembly.GetType(className); type is not null && type != typeof(object); type = type.BaseType)
+ {
+ var declared = type.GetCustomAttributesData()
+ .Where(attribute => attribute.AttributeType.FullName == "Xunit.TraitAttribute"
+ && attribute.ConstructorArguments.Count == 2
+ && attribute.ConstructorArguments[0].Value as string == "Area")
+ .Select(attribute => attribute.ConstructorArguments[1].Value as string)
+ .FirstOrDefault(value => value is not null);
+ if (declared is not null)
+ {
+ area = declared;
+ break;
+ }
+ }
+ known[className] = area;
+ return area;
+ }
+ }
+}