From 477dc235fc570c6f7b3515ee4bf58fcd0933cb44 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 09:37:15 +0000 Subject: [PATCH] Every test class says its area, and CI reports the suite's time by area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Area trait was on most test classes and nothing read it, so nothing noticed the classes without one: 22 files when #1183 was filed, 37 now. AreaTraitTest fails and names every test class with no [Trait("Area", …)], and each of the 37 now carries the area its folder already uses. The C# Test job's Ubuntu leg also writes a trx, and `dotnet run AreaTimes` in Sources/Utils/Utils adds a table of time by area to the job summary. The area is read from the test assembly, since a trx names a test's class and not its traits; a class with none is counted as (none), so the table adds up to the whole run. Closes #1183. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .github/workflows/CSharpTest.yml | 10 +- .../Algebra/EquationWithoutTheUnknownTest.cs | 1 + .../Algebra/PythagoreanEquationRewriteTest.cs | 1 + .../Calculus/BinomialIdentitiesTest.cs | 1 + .../UnitTests/Calculus/BinomialSumTest.cs | 1 + .../Calculus/BreakpointIntegrationTest.cs | 1 + .../Calculus/ConstantRateIntegrandTest.cs | 1 + .../UnitTests/Calculus/DivergentSeriesTest.cs | 1 + .../Calculus/ExponentialSeriesTest.cs | 1 + .../Tests/UnitTests/Calculus/ExtremumTest.cs | 1 + .../UnitTests/Calculus/GeometricSeriesTest.cs | 1 + .../Calculus/IntegralPiecewiseReducedTest.cs | 1 + .../InverseHyperbolicSubstitutionTest.cs | 1 + .../Calculus/LinearityBeforeByPartsTest.cs | 1 + .../UnitTests/Calculus/ModulusPhaseTest.cs | 1 + .../Calculus/PolynomialGeometricSeriesTest.cs | 1 + .../Calculus/PowersOfOneBaseMergedTest.cs | 1 + .../SymbolicQuadraticCoefficientTest.cs | 1 + .../Tests/UnitTests/Common/AreaTraitTest.cs | 52 +++++++ .../Common/IntervalSimplificationTest.cs | 1 + .../Common/LogarithmOfAPerfectPowerTest.cs | 1 + .../Common/PiecewiseReachabilityTest.cs | 1 + .../Convenience/SpellingsAtRiskTest.cs | 1 + .../Tests/UnitTests/Core/CardinalityTest.cs | 1 + .../Core/ContradictoryConjunctionTest.cs | 1 + Sources/Tests/UnitTests/Core/DividesTest.cs | 1 + .../Core/FactorialCacheIsThreadSafeTest.cs | 1 + .../UnitTests/Core/IntervalEvaluationTest.cs | 1 + .../UnitTests/Core/MatrixConcurrencyTest.cs | 1 + .../Tests/UnitTests/Core/SortKeyCacheTest.cs | 1 + .../Core/Transformations/EBindingsTest.cs | 1 + .../Core/Transformations/EGraphTest.cs | 1 + .../Transformations/MatchPatternEMatchTest.cs | 1 + .../Transformations/MatchedRuleGrowthTest.cs | 1 + .../MatchedRuleTryEMatchApplyTest.cs | 1 + .../Transformations/MatchedRulesAllTest.cs | 1 + .../Core/UnknownDomainIsNotABugTest.cs | 1 + .../Core/UnreachablePiecewiseCaseTest.cs | 1 + .../RationalizeDenominatorTest.cs | 1 + Sources/Utils/Utils/AreaTimes.cs | 131 ++++++++++++++++++ 40 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Sources/Tests/UnitTests/Common/AreaTraitTest.cs create mode 100644 Sources/Utils/Utils/AreaTimes.cs 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; + } + } +}