Skip to content

Commit 21d6ad8

Browse files
Merge pull request #74 from ktsu-dev/claude/issue-57-no-duplicates
test(quantities): generator-output invariants — no duplicate signatures, commutative * (closes #57)
2 parents 1cc44e2 + 415c6ba commit 21d6ad8

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) ktsu.dev
2+
// All rights reserved.
3+
// Licensed under the MIT license.
4+
5+
namespace ktsu.Semantics.Test.Quantities;
6+
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
using System.Reflection;
11+
using ktsu.Semantics.Quantities;
12+
using Microsoft.VisualStudio.TestTools.UnitTesting;
13+
14+
/// <summary>
15+
/// Locks in invariants on the source generator's output. Issue #57 raised the concern that
16+
/// the generator's dedup keys could let two methods (or operators) with the same name and
17+
/// parameter types land on the same type — which the C# compiler already rejects, but this
18+
/// test makes the property explicit and regression-proof if the dedup logic changes.
19+
/// </summary>
20+
[TestClass]
21+
public sealed class GeneratorOutputInvariantTests
22+
{
23+
/// <summary>
24+
/// For every generated quantity type in <c>ktsu.Semantics.Quantities</c>, no two public
25+
/// static methods (including operators) share both the same name and the same parameter
26+
/// type list. Walks the runtime assembly rather than re-parsing the .g.cs files because
27+
/// the compiled types are the source of truth — anything the test sees is what consumers
28+
/// would call.
29+
/// </summary>
30+
[TestMethod]
31+
public void NoDuplicatePublicStaticMethodsOrOperatorsPerGeneratedType()
32+
{
33+
Assembly assembly = typeof(Mass<>).Assembly;
34+
List<Type> generatedQuantityTypes = [.. CollectGeneratedQuantityTypes(assembly)];
35+
36+
// Sanity: we should be looking at a non-trivial set, otherwise the test is silently
37+
// vacuous (e.g. namespace got renamed and the filter dropped everything).
38+
Assert.IsTrue(
39+
generatedQuantityTypes.Count > 50,
40+
$"Expected to find many generated quantity types (got {generatedQuantityTypes.Count}). The filter likely needs updating.");
41+
42+
List<string> failures = [];
43+
foreach (Type type in generatedQuantityTypes)
44+
{
45+
MethodInfo[] staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly);
46+
47+
IEnumerable<IGrouping<string, MethodInfo>> groups = staticMethods.GroupBy(SignatureKey);
48+
foreach (IGrouping<string, MethodInfo> group in groups)
49+
{
50+
int count = group.Count();
51+
if (count > 1)
52+
{
53+
failures.Add($"{type.Name}: {group.Key} appears {count} times");
54+
}
55+
}
56+
}
57+
58+
if (failures.Count > 0)
59+
{
60+
Assert.Fail(
61+
$"Found duplicate public static method signatures on generated quantity types:\n " +
62+
string.Join("\n ", failures));
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Cross-dimensional <c>operator *</c> overloads should exist in both operand orders so
68+
/// either-order user code (<c>mass * accel</c> and <c>accel * mass</c>) compiles. The
69+
/// generator's <c>CollectAllOperators</c> emits both directions; this test asserts the
70+
/// commutativity property explicitly so a regression in the dedup keys would fail here
71+
/// before it reaches a downstream consumer.
72+
/// </summary>
73+
[TestMethod]
74+
public void EveryCrossDimensionalMultiplicationHasBothOperandOrders()
75+
{
76+
Assembly assembly = typeof(Mass<>).Assembly;
77+
List<Type> types = [.. CollectGeneratedQuantityTypes(assembly)];
78+
79+
// Collect every observed operator * signature as a tuple (left, right, returnType).
80+
HashSet<(string Left, string Right, string Result)> observed = [];
81+
foreach (Type type in types)
82+
{
83+
foreach (MethodInfo m in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly))
84+
{
85+
if (m.Name != "op_Multiply")
86+
{
87+
continue;
88+
}
89+
90+
ParameterInfo[] pars = m.GetParameters();
91+
if (pars.Length != 2)
92+
{
93+
continue;
94+
}
95+
96+
observed.Add((pars[0].ParameterType.Name, pars[1].ParameterType.Name, m.ReturnType.Name));
97+
}
98+
}
99+
100+
// For every cross-dimensional product (operands of distinct types), the swapped pair
101+
// should also be present with the same return. Same-type products (T * T) are exempt
102+
// because there is no swap — they're idempotent under reorder.
103+
List<string> missing = [];
104+
foreach ((string left, string right, string result) in observed)
105+
{
106+
if (left == right)
107+
{
108+
continue;
109+
}
110+
111+
if (!observed.Contains((right, left, result)))
112+
{
113+
missing.Add($"missing reverse pair: {right} * {left} -> {result} (forward {left} * {right} -> {result} exists)");
114+
}
115+
}
116+
117+
if (missing.Count > 0)
118+
{
119+
Assert.Fail(
120+
"Cross-dimensional multiplication should be emitted in both operand orders, but found " +
121+
$"{missing.Count} unmatched forward(s):\n " +
122+
string.Join("\n ", missing));
123+
}
124+
}
125+
126+
private static IEnumerable<Type> CollectGeneratedQuantityTypes(Assembly assembly)
127+
{
128+
foreach (Type type in assembly.GetTypes())
129+
{
130+
if (type.Namespace != "ktsu.Semantics.Quantities")
131+
{
132+
continue;
133+
}
134+
135+
if (!type.IsGenericTypeDefinition)
136+
{
137+
continue;
138+
}
139+
140+
// Generated quantity types implement one of IVector0..IVector4 (closed over TSelf, T).
141+
bool isQuantity = type.GetInterfaces().Any(static i =>
142+
i.IsGenericType && i.Name.StartsWith("IVector", StringComparison.Ordinal));
143+
if (!isQuantity)
144+
{
145+
continue;
146+
}
147+
148+
yield return type;
149+
}
150+
}
151+
152+
private static string SignatureKey(MethodInfo m)
153+
{
154+
string parameterList = string.Join(",", m.GetParameters().Select(static p => p.ParameterType.FullName ?? p.ParameterType.Name));
155+
return $"{m.Name}({parameterList})";
156+
}
157+
}

0 commit comments

Comments
 (0)