Skip to content

Commit ee7e2d0

Browse files
authored
Merge pull request #299 from ifBars/agent/fix-291-coverage-provenance
2 parents 1bbf3f0 + 51fa37d commit ee7e2d0

10 files changed

Lines changed: 460 additions & 161 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using System.Text.Json;
2+
using S1APICoverageAnalyzer.Analysis;
3+
using S1APICoverageAnalyzer.Models;
4+
using S1APICoverageAnalyzer.Output;
5+
using Xunit;
6+
7+
namespace S1API.Tests.Coverage;
8+
9+
public sealed class CoverageAnalyzerTests
10+
{
11+
[Fact]
12+
public void ApiAnalyzer_RecordsExplicitMappingsAndUnwrapsElementTypes()
13+
{
14+
var apiAssembly = typeof(global::S1API.Temperature.TemperatureUtility).Assembly;
15+
var analyzer = new ApiAssemblyAnalyzer(apiAssembly, apiAssembly.Location);
16+
17+
analyzer.Analyze();
18+
19+
IReadOnlyDictionary<string, string> explicitMappings =
20+
analyzer.GetExplicitCoverageMappings();
21+
Assert.Equal(
22+
"S1API.Temperature.TemperatureEmitterInfo",
23+
explicitMappings["ScheduleOne.Temperature.TemperatureEmitterInfo"]);
24+
Assert.Equal(
25+
"S1API.Temperature.TemperatureUtility",
26+
explicitMappings["ScheduleOne.Temperature.TemperatureUtility"]);
27+
28+
Assert.Contains(
29+
"ScheduleOne.Temperature.TemperatureEmitterInfo",
30+
analyzer.GetWrappedGameTypes());
31+
Assert.DoesNotContain(
32+
"ScheduleOne.Temperature.TemperatureEmitterInfo[]",
33+
analyzer.GetWrappedGameTypes());
34+
}
35+
36+
[Fact]
37+
public void Calculate_ReportsProvenanceAndMatchStrategyForEveryCoveredType()
38+
{
39+
var gameTypes = new List<GameType>
40+
{
41+
GameType("ScheduleOne.Temperature.TemperatureUtility"),
42+
GameType("ScheduleOne.Items.ItemDefinition"),
43+
GameType("ScheduleOne.Casino.BlackjackGameController+EStage"),
44+
GameType("ScheduleOne.Dialogue.DialogueController.Node"),
45+
GameType("ScheduleOne.Vehicles.Modification.EVehicleColor")
46+
};
47+
var apiTypes = new List<ApiTypeInfo>
48+
{
49+
ApiType(
50+
"S1API.Casino.BlackjackGame",
51+
"BlackjackGame",
52+
"ScheduleOne.Casino.BlackjackGameController"),
53+
ApiType(
54+
"S1API.Dialogue.DialogueNode",
55+
"DialogueNode",
56+
"ScheduleOne.Dialogue.DialogueController+Node"),
57+
ApiType(
58+
"S1API.Items.ItemDefinition",
59+
"ItemDefinition",
60+
"ScheduleOne.Items.ItemDefinition"),
61+
ApiType(
62+
"S1API.Temperature.TemperatureUtility",
63+
"TemperatureUtility",
64+
"ScheduleOne.Temperature.TemperatureUtility"),
65+
ApiType(
66+
"S1API.Vehicles.VehicleColor",
67+
"VehicleColor",
68+
"ScheduleOne.Vehicles.Modification.VehicleColors")
69+
};
70+
var explicitMappings = new Dictionary<string, string>(StringComparer.Ordinal)
71+
{
72+
["ScheduleOne.Temperature.TemperatureUtility"] =
73+
"S1API.Temperature.TemperatureUtility"
74+
};
75+
76+
CoverageResult result = Calculate(gameTypes, apiTypes, explicitMappings);
77+
78+
Assert.Collection(
79+
result.CoveredTypes.OrderBy(type => type.FullName, StringComparer.Ordinal),
80+
type => AssertMatch(type, "S1API.Casino.BlackjackGame", CoverageMatchStrategy.Nested),
81+
type => AssertMatch(type, "S1API.Dialogue.DialogueNode", CoverageMatchStrategy.Normalized),
82+
type => AssertMatch(type, "S1API.Items.ItemDefinition", CoverageMatchStrategy.Exact),
83+
type => AssertMatch(type, "S1API.Temperature.TemperatureUtility", CoverageMatchStrategy.Explicit),
84+
type => AssertMatch(type, "S1API.Vehicles.VehicleColor", CoverageMatchStrategy.Fuzzy));
85+
Assert.Empty(result.UncoveredTypes);
86+
87+
using JsonDocument report = JsonDocument.Parse(ReportGenerator.GenerateJsonReport(result));
88+
foreach (JsonElement coveredType in report.RootElement.GetProperty("coveredTypes").EnumerateArray())
89+
{
90+
Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("coveredBy").GetString()));
91+
Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("matchStrategy").GetString()));
92+
}
93+
}
94+
95+
[Fact]
96+
public void Calculate_DoesNotFuzzyMatchSimilarUnrelatedType()
97+
{
98+
GameType unrelatedGameType =
99+
GameType("ScheduleOne.Vehicles.VehicleSeatSnapshot");
100+
ApiTypeInfo similarlyNamedApiType = ApiType(
101+
"S1API.Items.VehicleSeat",
102+
"VehicleSeat",
103+
"ScheduleOne.ItemFramework.ItemSlot");
104+
105+
CoverageResult result = Calculate(
106+
new List<GameType> { unrelatedGameType },
107+
new List<ApiTypeInfo> { similarlyNamedApiType },
108+
new Dictionary<string, string>());
109+
110+
Assert.Empty(result.CoveredTypes);
111+
Assert.Same(unrelatedGameType, Assert.Single(result.UncoveredTypes));
112+
}
113+
114+
[Fact]
115+
public void Calculate_UsesDeterministicApiTypeForEquivalentMatches()
116+
{
117+
GameType gameType = GameType("ScheduleOne.Items.ItemDefinition");
118+
var apiTypes = new List<ApiTypeInfo>
119+
{
120+
ApiType(
121+
"S1API.Zeta.ItemDefinition",
122+
"ItemDefinition",
123+
gameType.FullName),
124+
ApiType(
125+
"S1API.Alpha.ItemDefinition",
126+
"ItemDefinition",
127+
gameType.FullName)
128+
};
129+
130+
CoverageResult result = Calculate(
131+
new List<GameType> { gameType },
132+
apiTypes,
133+
new Dictionary<string, string>());
134+
135+
AssertMatch(
136+
Assert.Single(result.CoveredTypes),
137+
"S1API.Alpha.ItemDefinition",
138+
CoverageMatchStrategy.Exact);
139+
}
140+
141+
private static CoverageResult Calculate(
142+
List<GameType> gameTypes,
143+
List<ApiTypeInfo> apiTypes,
144+
IReadOnlyDictionary<string, string> explicitMappings)
145+
{
146+
var calculator = new CoverageCalculator(
147+
gameTypes,
148+
new Dictionary<string, HashSet<string>>(StringComparer.Ordinal),
149+
apiTypes,
150+
explicitMappings,
151+
excludedTypeCount: 0);
152+
return calculator.Calculate();
153+
}
154+
155+
private static GameType GameType(string fullName)
156+
{
157+
int separatorIndex = fullName.LastIndexOfAny(['.', '+']);
158+
return new GameType
159+
{
160+
FullName = fullName,
161+
Namespace = separatorIndex < 0 ? string.Empty : fullName[..separatorIndex],
162+
Name = separatorIndex < 0 ? fullName : fullName[(separatorIndex + 1)..],
163+
Kind = GameTypeKind.Class
164+
};
165+
}
166+
167+
private static ApiTypeInfo ApiType(
168+
string fullName,
169+
string name,
170+
params string[] wrappedGameTypes) =>
171+
new()
172+
{
173+
FullName = fullName,
174+
Name = name,
175+
WrappedGameTypes = wrappedGameTypes.ToList()
176+
};
177+
178+
private static void AssertMatch(
179+
GameType gameType,
180+
string expectedApiType,
181+
CoverageMatchStrategy expectedStrategy)
182+
{
183+
Assert.Equal(expectedApiType, gameType.CoveredByApiType);
184+
Assert.Equal(expectedStrategy, gameType.MatchStrategy);
185+
}
186+
}

S1API.Tests/S1API.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<PrivateAssets>all</PrivateAssets>
2929
</PackageReference>
3030
<ProjectReference Include="../S1API/S1API.csproj" />
31+
<ProjectReference Include="../tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj" />
3132
</ItemGroup>
3233

3334
<ItemGroup Condition="'$(Configuration)' == 'MonoMelon'">

tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public sealed class ApiAssemblyAnalyzer : AssemblyAnalyzer
1212
{
1313
private readonly HashSet<string> _wrappedGameTypes = new();
1414
private readonly Dictionary<string, HashSet<string>> _typeToAccessedMembers = new();
15+
private readonly Dictionary<string, string> _explicitCoverageMappings = new(StringComparer.Ordinal);
1516
private readonly List<ApiTypeInfo> _apiTypes = new();
1617

1718
public ApiAssemblyAnalyzer(Assembly assembly, string assemblyPath)
@@ -67,12 +68,20 @@ public void Analyze()
6768

6869
// Strategy 9: Attributes that reference game types
6970
AnalyzeAttributes(type, apiTypeInfo);
71+
72+
// Strategy 10: Analyzer-owned declarations for runtime-agnostic mirrors.
73+
AnalyzeExplicitCoverage(type, apiTypeInfo);
7074

7175
if (apiTypeInfo.WrappedGameTypes.Count > 0)
7276
{
7377
_apiTypes.Add(apiTypeInfo);
7478
}
7579
}
80+
81+
_apiTypes.Sort((left, right) =>
82+
StringComparer.Ordinal.Compare(left.FullName, right.FullName));
83+
84+
ValidateExplicitCoverageMappings();
7685
}
7786

7887
/// <summary>
@@ -89,6 +98,12 @@ public void Analyze()
8998
/// Get information about all API types that wrap game types.
9099
/// </summary>
91100
public List<ApiTypeInfo> GetApiTypes() => _apiTypes;
101+
102+
/// <summary>
103+
/// Get semantic coverage declarations keyed by game type name.
104+
/// </summary>
105+
public IReadOnlyDictionary<string, string> GetExplicitCoverageMappings() =>
106+
_explicitCoverageMappings;
92107

93108
/// <summary>
94109
/// Analyze fields that are primary wrappers (S1*, Inner*, etc.).
@@ -230,14 +245,20 @@ private void AnalyzeSameNameWrapping(Type apiType, ApiTypeInfo apiTypeInfo)
230245

231246
private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiTypeInfo)
232247
{
248+
if (type.HasElementType)
249+
{
250+
var elementType = type.GetElementType();
251+
if (elementType != null)
252+
RegisterGameTypeReference(elementType, apiType, apiTypeInfo);
253+
return;
254+
}
255+
233256
if (IsGameType(type))
234257
{
235258
var normalizedName = NormalizeScheduleOneTypeName(type.FullName);
236259
if (!string.IsNullOrEmpty(normalizedName))
237260
{
238-
_wrappedGameTypes.Add(normalizedName);
239-
apiTypeInfo.WrappedGameTypes.Add(normalizedName);
240-
TrackTypeAccess(normalizedName, apiType);
261+
RegisterGameTypeName(normalizedName, apiType, apiTypeInfo);
241262
}
242263

243264
if (type.DeclaringType != null && IsGameType(type.DeclaringType))
@@ -256,14 +277,42 @@ private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiT
256277
var normalizedName = NormalizeScheduleOneTypeName(arg.FullName);
257278
if (!string.IsNullOrEmpty(normalizedName))
258279
{
259-
_wrappedGameTypes.Add(normalizedName);
260-
apiTypeInfo.WrappedGameTypes.Add(normalizedName);
261-
TrackTypeAccess(normalizedName, apiType);
280+
RegisterGameTypeName(normalizedName, apiType, apiTypeInfo);
262281
}
263282
}
264283
}
265284
}
266285
}
286+
287+
private void AnalyzeExplicitCoverage(Type apiType, ApiTypeInfo apiTypeInfo)
288+
{
289+
foreach (var gameTypeName in ExplicitCoverageConfig.GetGameTypesCoveredBy(apiTypeInfo.FullName))
290+
{
291+
RegisterGameTypeName(gameTypeName, apiType, apiTypeInfo);
292+
_explicitCoverageMappings.Add(gameTypeName, apiTypeInfo.FullName);
293+
}
294+
}
295+
296+
private void ValidateExplicitCoverageMappings()
297+
{
298+
foreach (var mapping in ExplicitCoverageConfig.GetMappings())
299+
{
300+
if (_explicitCoverageMappings.ContainsKey(mapping.Key))
301+
continue;
302+
303+
throw new InvalidOperationException(
304+
$"Explicit coverage mapping for '{mapping.Key}' references " +
305+
$"missing API type '{mapping.Value}'.");
306+
}
307+
}
308+
309+
private void RegisterGameTypeName(string gameTypeName, Type apiType, ApiTypeInfo apiTypeInfo)
310+
{
311+
_wrappedGameTypes.Add(gameTypeName);
312+
if (!apiTypeInfo.WrappedGameTypes.Contains(gameTypeName, StringComparer.Ordinal))
313+
apiTypeInfo.WrappedGameTypes.Add(gameTypeName);
314+
TrackTypeAccess(gameTypeName, apiType);
315+
}
267316

268317
private bool IsGameType(Type type)
269318
{

0 commit comments

Comments
 (0)