Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions S1API.Tests/Coverage/CoverageAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System.Text.Json;
using S1APICoverageAnalyzer.Analysis;
using S1APICoverageAnalyzer.Models;
using S1APICoverageAnalyzer.Output;
using Xunit;

namespace S1API.Tests.Coverage;

public sealed class CoverageAnalyzerTests
{
[Fact]
public void ApiAnalyzer_RecordsExplicitMappingsAndUnwrapsElementTypes()
{
var apiAssembly = typeof(global::S1API.Temperature.TemperatureUtility).Assembly;
var analyzer = new ApiAssemblyAnalyzer(apiAssembly, apiAssembly.Location);

analyzer.Analyze();

IReadOnlyDictionary<string, string> explicitMappings =
analyzer.GetExplicitCoverageMappings();
Assert.Equal(
"S1API.Temperature.TemperatureEmitterInfo",
explicitMappings["ScheduleOne.Temperature.TemperatureEmitterInfo"]);
Assert.Equal(
"S1API.Temperature.TemperatureUtility",
explicitMappings["ScheduleOne.Temperature.TemperatureUtility"]);

Assert.Contains(
"ScheduleOne.Temperature.TemperatureEmitterInfo",
analyzer.GetWrappedGameTypes());
Assert.DoesNotContain(
"ScheduleOne.Temperature.TemperatureEmitterInfo[]",
analyzer.GetWrappedGameTypes());
}

[Fact]
public void Calculate_ReportsProvenanceAndMatchStrategyForEveryCoveredType()
{
var gameTypes = new List<GameType>
{
GameType("ScheduleOne.Temperature.TemperatureUtility"),
GameType("ScheduleOne.Items.ItemDefinition"),
GameType("ScheduleOne.Casino.BlackjackGameController+EStage"),
GameType("ScheduleOne.Dialogue.DialogueController.Node"),
GameType("ScheduleOne.Vehicles.Modification.EVehicleColor")
};
var apiTypes = new List<ApiTypeInfo>
{
ApiType(
"S1API.Casino.BlackjackGame",
"BlackjackGame",
"ScheduleOne.Casino.BlackjackGameController"),
ApiType(
"S1API.Dialogue.DialogueNode",
"DialogueNode",
"ScheduleOne.Dialogue.DialogueController+Node"),
ApiType(
"S1API.Items.ItemDefinition",
"ItemDefinition",
"ScheduleOne.Items.ItemDefinition"),
ApiType(
"S1API.Temperature.TemperatureUtility",
"TemperatureUtility",
"ScheduleOne.Temperature.TemperatureUtility"),
ApiType(
"S1API.Vehicles.VehicleColor",
"VehicleColor",
"ScheduleOne.Vehicles.Modification.VehicleColors")
};
var explicitMappings = new Dictionary<string, string>(StringComparer.Ordinal)
{
["ScheduleOne.Temperature.TemperatureUtility"] =
"S1API.Temperature.TemperatureUtility"
};

CoverageResult result = Calculate(gameTypes, apiTypes, explicitMappings);

Assert.Collection(
result.CoveredTypes.OrderBy(type => type.FullName, StringComparer.Ordinal),
type => AssertMatch(type, "S1API.Casino.BlackjackGame", CoverageMatchStrategy.Nested),
type => AssertMatch(type, "S1API.Dialogue.DialogueNode", CoverageMatchStrategy.Normalized),
type => AssertMatch(type, "S1API.Items.ItemDefinition", CoverageMatchStrategy.Exact),
type => AssertMatch(type, "S1API.Temperature.TemperatureUtility", CoverageMatchStrategy.Explicit),
type => AssertMatch(type, "S1API.Vehicles.VehicleColor", CoverageMatchStrategy.Fuzzy));
Assert.Empty(result.UncoveredTypes);

using JsonDocument report = JsonDocument.Parse(ReportGenerator.GenerateJsonReport(result));
foreach (JsonElement coveredType in report.RootElement.GetProperty("coveredTypes").EnumerateArray())
{
Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("coveredBy").GetString()));
Assert.False(string.IsNullOrWhiteSpace(coveredType.GetProperty("matchStrategy").GetString()));
}
}

[Fact]
public void Calculate_DoesNotFuzzyMatchSimilarUnrelatedType()
{
GameType unrelatedGameType =
GameType("ScheduleOne.Vehicles.VehicleSeatSnapshot");
ApiTypeInfo similarlyNamedApiType = ApiType(
"S1API.Items.VehicleSeat",
"VehicleSeat",
"ScheduleOne.ItemFramework.ItemSlot");

CoverageResult result = Calculate(
new List<GameType> { unrelatedGameType },
new List<ApiTypeInfo> { similarlyNamedApiType },
new Dictionary<string, string>());

Assert.Empty(result.CoveredTypes);
Assert.Same(unrelatedGameType, Assert.Single(result.UncoveredTypes));
}

[Fact]
public void Calculate_UsesDeterministicApiTypeForEquivalentMatches()
{
GameType gameType = GameType("ScheduleOne.Items.ItemDefinition");
var apiTypes = new List<ApiTypeInfo>
{
ApiType(
"S1API.Zeta.ItemDefinition",
"ItemDefinition",
gameType.FullName),
ApiType(
"S1API.Alpha.ItemDefinition",
"ItemDefinition",
gameType.FullName)
};

CoverageResult result = Calculate(
new List<GameType> { gameType },
apiTypes,
new Dictionary<string, string>());

AssertMatch(
Assert.Single(result.CoveredTypes),
"S1API.Alpha.ItemDefinition",
CoverageMatchStrategy.Exact);
}

private static CoverageResult Calculate(
List<GameType> gameTypes,
List<ApiTypeInfo> apiTypes,
IReadOnlyDictionary<string, string> explicitMappings)
{
var calculator = new CoverageCalculator(
gameTypes,
new Dictionary<string, HashSet<string>>(StringComparer.Ordinal),
apiTypes,
explicitMappings,
excludedTypeCount: 0);
return calculator.Calculate();
}

private static GameType GameType(string fullName)
{
int separatorIndex = fullName.LastIndexOfAny(['.', '+']);
return new GameType
{
FullName = fullName,
Namespace = separatorIndex < 0 ? string.Empty : fullName[..separatorIndex],
Name = separatorIndex < 0 ? fullName : fullName[(separatorIndex + 1)..],
Kind = GameTypeKind.Class
};
}

private static ApiTypeInfo ApiType(
string fullName,
string name,
params string[] wrappedGameTypes) =>
new()
{
FullName = fullName,
Name = name,
WrappedGameTypes = wrappedGameTypes.ToList()
};

private static void AssertMatch(
GameType gameType,
string expectedApiType,
CoverageMatchStrategy expectedStrategy)
{
Assert.Equal(expectedApiType, gameType.CoveredByApiType);
Assert.Equal(expectedStrategy, gameType.MatchStrategy);
}
}
1 change: 1 addition & 0 deletions S1API.Tests/S1API.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="../S1API/S1API.csproj" />
<ProjectReference Include="../tools/S1APICoverageAnalyzer/S1APICoverageAnalyzer.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(Configuration)' == 'MonoMelon'">
Expand Down
61 changes: 55 additions & 6 deletions tools/S1APICoverageAnalyzer/Analysis/ApiAssemblyAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed class ApiAssemblyAnalyzer : AssemblyAnalyzer
{
private readonly HashSet<string> _wrappedGameTypes = new();
private readonly Dictionary<string, HashSet<string>> _typeToAccessedMembers = new();
private readonly Dictionary<string, string> _explicitCoverageMappings = new(StringComparer.Ordinal);
private readonly List<ApiTypeInfo> _apiTypes = new();

public ApiAssemblyAnalyzer(Assembly assembly, string assemblyPath)
Expand Down Expand Up @@ -67,12 +68,20 @@ public void Analyze()

// Strategy 9: Attributes that reference game types
AnalyzeAttributes(type, apiTypeInfo);

// Strategy 10: Analyzer-owned declarations for runtime-agnostic mirrors.
AnalyzeExplicitCoverage(type, apiTypeInfo);

if (apiTypeInfo.WrappedGameTypes.Count > 0)
{
_apiTypes.Add(apiTypeInfo);
}
}

_apiTypes.Sort((left, right) =>
StringComparer.Ordinal.Compare(left.FullName, right.FullName));

ValidateExplicitCoverageMappings();
}

/// <summary>
Expand All @@ -89,6 +98,12 @@ public void Analyze()
/// Get information about all API types that wrap game types.
/// </summary>
public List<ApiTypeInfo> GetApiTypes() => _apiTypes;

/// <summary>
/// Get semantic coverage declarations keyed by game type name.
/// </summary>
public IReadOnlyDictionary<string, string> GetExplicitCoverageMappings() =>
_explicitCoverageMappings;

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

private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiTypeInfo)
{
if (type.HasElementType)
{
var elementType = type.GetElementType();
if (elementType != null)
RegisterGameTypeReference(elementType, apiType, apiTypeInfo);
return;
}

if (IsGameType(type))
{
var normalizedName = NormalizeScheduleOneTypeName(type.FullName);
if (!string.IsNullOrEmpty(normalizedName))
{
_wrappedGameTypes.Add(normalizedName);
apiTypeInfo.WrappedGameTypes.Add(normalizedName);
TrackTypeAccess(normalizedName, apiType);
RegisterGameTypeName(normalizedName, apiType, apiTypeInfo);
}

if (type.DeclaringType != null && IsGameType(type.DeclaringType))
Expand All @@ -256,14 +277,42 @@ private void RegisterGameTypeReference(Type type, Type apiType, ApiTypeInfo apiT
var normalizedName = NormalizeScheduleOneTypeName(arg.FullName);
if (!string.IsNullOrEmpty(normalizedName))
{
_wrappedGameTypes.Add(normalizedName);
apiTypeInfo.WrappedGameTypes.Add(normalizedName);
TrackTypeAccess(normalizedName, apiType);
RegisterGameTypeName(normalizedName, apiType, apiTypeInfo);
}
}
}
}
}

private void AnalyzeExplicitCoverage(Type apiType, ApiTypeInfo apiTypeInfo)
{
foreach (var gameTypeName in ExplicitCoverageConfig.GetGameTypesCoveredBy(apiTypeInfo.FullName))
{
RegisterGameTypeName(gameTypeName, apiType, apiTypeInfo);
_explicitCoverageMappings.Add(gameTypeName, apiTypeInfo.FullName);
}
}

private void ValidateExplicitCoverageMappings()
{
foreach (var mapping in ExplicitCoverageConfig.GetMappings())
{
if (_explicitCoverageMappings.ContainsKey(mapping.Key))
continue;

throw new InvalidOperationException(
$"Explicit coverage mapping for '{mapping.Key}' references " +
$"missing API type '{mapping.Value}'.");
}
}

private void RegisterGameTypeName(string gameTypeName, Type apiType, ApiTypeInfo apiTypeInfo)
{
_wrappedGameTypes.Add(gameTypeName);
if (!apiTypeInfo.WrappedGameTypes.Contains(gameTypeName, StringComparer.Ordinal))
apiTypeInfo.WrappedGameTypes.Add(gameTypeName);
TrackTypeAccess(gameTypeName, apiType);
}

private bool IsGameType(Type type)
{
Expand Down
Loading
Loading