CI / build-test (push) Failing after 1m7s
Collisions: init bucket heads to -1 (QueryAabb hung before the first rebuild), reset query stamps on truncated QueryAabb (later queries silently dropped entities), inside-origin raycasts hit at fraction 0 for circles too, exactly-touching boxes now pair like touching circles. Graphics: render into the letterbox viewport so the picture matches ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the transform pivot rather than the quad center; lock-free snapshot LayerRegistry (parallel submit read it unsynchronized); validate InitialCapacity; warn when UseRenderer2D drops options of a later scene. Core: scenes are explicitly single-use (re-loading threw silently duplicated systems/entities before — now it throws), Scene.RegisterUnload for per-scene resources, a switch requested during the reveal phase covers again instead of hard-swapping, borderless fullscreen (HardwareModeSwitch off), InputCapture service for input-suppressing overlays, host disposes the transition renderer and IDisposable services on shutdown. Input: game input reads as released while InputCapture is held; mouse position and wheel freeze so deltas stay zero. DevConsole: holds InputCapture while open (typing no longer drives the camera), Revision increments only under the lock, quoted command arguments, history capped at 256. UI: scene Desktop skips Myra input processing while the console is open (clicks no longer fall through), is disposed on scene unload, and Myra init no longer depends on a process-static flag. Audio: validate channel count/sample rate before stopping the previous track, empty looped oggs no longer hang FillBuffers, the instance stops when a non-looping track drains (IsPlaying was stuck true). Atlases: metadata v2 stores per-source size+mtime snapshots, so timestamp-preserving copies and renames invalidate correctly; loader checks the version and disposes pages on partial load failure; shared pages never exceed a non-POT MaxPageSize; oversized items pack first onto exact-size pages instead of splitting an open shared page; the CLI validates numeric options. Assets.Generator: file names are escaped in XML docs and string literals, members no longer collide with the enclosing class (CS0542), and the Assets root is resolved against build_property.projectdir so nested "Assets" directories do not shift region paths. Pathfinding: queries throw when the grid was resized after construction; generation stamps survive int overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
7.5 KiB
C#
189 lines
7.5 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.Diagnostics;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
using MrGameEng.Assets.Generator;
|
|
using Xunit;
|
|
|
|
namespace MrGameEng.Assets.Generator.Tests;
|
|
|
|
public class AssetHandlesGeneratorTests
|
|
{
|
|
private sealed class FakeAdditionalText(string path) : AdditionalText
|
|
{
|
|
public override string Path { get; } = path;
|
|
|
|
public override SourceText GetText(CancellationToken cancellationToken = default) =>
|
|
SourceText.From(string.Empty);
|
|
}
|
|
|
|
private sealed class FakeOptions(Dictionary<string, string> values) : AnalyzerConfigOptions
|
|
{
|
|
public override bool TryGetValue(string key, out string value) =>
|
|
values.TryGetValue(key, out value!);
|
|
}
|
|
|
|
private sealed class FakeOptionsProvider(Dictionary<string, string> values) : AnalyzerConfigOptionsProvider
|
|
{
|
|
public override AnalyzerConfigOptions GlobalOptions { get; } = new FakeOptions(values);
|
|
|
|
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions;
|
|
|
|
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions;
|
|
}
|
|
|
|
private static string RunGenerator(string[] files, Dictionary<string, string>? options = null)
|
|
{
|
|
var driver = CSharpGeneratorDriver.Create(
|
|
[new AssetHandlesGenerator().AsSourceGenerator()],
|
|
additionalTexts: Array.ConvertAll(files, f => (AdditionalText)new FakeAdditionalText(f)),
|
|
optionsProvider: new FakeOptionsProvider(options ?? new Dictionary<string, string>
|
|
{
|
|
["build_property.RootNamespace"] = "MyGame",
|
|
}));
|
|
|
|
var compilation = CSharpCompilation.Create("test");
|
|
var result = driver.RunGenerators(compilation).GetRunResult();
|
|
return Assert.Single(Assert.Single(result.Results).GeneratedSources).SourceText.ToString();
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneratesTypedHandles_ForKnownExtensions()
|
|
{
|
|
var source = RunGenerator(
|
|
[
|
|
@"D:\game\Assets\Textures\player.png",
|
|
@"D:\game\Assets\Sounds\jump.wav",
|
|
@"D:\game\Assets\Fonts\main.ttf",
|
|
@"D:\game\Assets\Music\theme.ogg",
|
|
]);
|
|
|
|
Assert.Contains("namespace MyGame;", source);
|
|
Assert.Contains("public static partial class GameAssets", source);
|
|
Assert.Contains("public static class Textures", source);
|
|
Assert.Contains(
|
|
"AssetRef<global::Microsoft.Xna.Framework.Graphics.Texture2D> Player = new(\"Textures/player.png\")",
|
|
source);
|
|
Assert.Contains(
|
|
"AssetRef<global::Microsoft.Xna.Framework.Audio.SoundEffect> Jump = new(\"Sounds/jump.wav\")",
|
|
source);
|
|
Assert.Contains("AssetRef<global::FontStashSharp.FontSystem> Main", source);
|
|
Assert.Contains("AssetRef<global::MrGameEng.Core.MusicTrack> Theme", source);
|
|
}
|
|
|
|
[Fact]
|
|
public void IgnoresUnknownExtensions_AndFilesOutsideAssets()
|
|
{
|
|
var source = RunGenerator(
|
|
[
|
|
@"D:\game\Assets\readme.md",
|
|
@"D:\game\Other\image.png",
|
|
@"D:\game\Assets\valid.png",
|
|
]);
|
|
|
|
Assert.Contains("Valid", source);
|
|
Assert.DoesNotContain("Readme", source);
|
|
Assert.DoesNotContain("Image", source);
|
|
}
|
|
|
|
[Fact]
|
|
public void AtlasFiles_GetTextureAtlasHandles_AndPagesAreExcluded()
|
|
{
|
|
var source = RunGenerator(
|
|
[
|
|
@"D:\game\Assets\Atlases\Things.Pawn.atlas",
|
|
@"D:\game\Assets\Atlases\Things.Pawn.atlas.0.png",
|
|
@"D:\game\Assets\Atlases\Things.Pawn.atlas.1.png",
|
|
]);
|
|
|
|
Assert.Contains(
|
|
"AssetRef<global::MrGameEng.Atlases.TextureAtlas> ThingsPawn = new(\"Atlases/Things.Pawn.atlas\")",
|
|
source);
|
|
Assert.DoesNotContain("Texture2D> ThingsPawn", source);
|
|
}
|
|
|
|
[Fact]
|
|
public void NestedDirectories_BecomeNestedClasses()
|
|
{
|
|
var source = RunGenerator([@"C:\proj\Assets\UI\Icons\save-icon.png"]);
|
|
|
|
Assert.Contains("public static class UI", source);
|
|
Assert.Contains("public static class Icons", source);
|
|
Assert.Contains("SaveIcon = new(\"UI/Icons/save-icon.png\")", source);
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneratedCode_Compiles()
|
|
{
|
|
var source = RunGenerator([@"D:\game\Assets\player.png"]);
|
|
|
|
// Подменяем внешние типы заглушками, чтобы скомпилировать сгенерированный код изолированно.
|
|
const string stubs = """
|
|
namespace MrGameEng.Assets { public readonly record struct AssetRef<T>(string Path) where T : class; }
|
|
namespace Microsoft.Xna.Framework.Graphics { public sealed class Texture2D; }
|
|
""";
|
|
|
|
var compilation = CSharpCompilation.Create(
|
|
"generated",
|
|
[CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken),
|
|
CSharpSyntaxTree.ParseText(stubs, cancellationToken: TestContext.Current.CancellationToken)],
|
|
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
|
|
MetadataReference.CreateFromFile(System.Runtime.Loader.AssemblyLoadContext.Default
|
|
.LoadFromAssemblyName(new System.Reflection.AssemblyName("System.Runtime")).Location)],
|
|
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
|
|
|
var errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken)
|
|
.Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
|
|
Assert.Empty(errors);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("player", "Player")]
|
|
[InlineData("save-icon", "SaveIcon")]
|
|
[InlineData("8bit_font", "_8bitFont")]
|
|
[InlineData("...", "_")]
|
|
public void Identifier_SanitizesNames(string input, string expected)
|
|
{
|
|
Assert.Equal(expected, AssetHandlesGenerator.Identifier(input));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(@"D:\game\Assets\a.png", "a.png")]
|
|
[InlineData("/home/user/game/Assets/sub/b.wav", "sub/b.wav")]
|
|
[InlineData(@"D:\game\NotAssets\c.png", null)]
|
|
[InlineData(@"D:\game\Assets\unknown.xyz", null)]
|
|
public void ToAssetPath_ExtractsRelativePath(string fullPath, string? expected)
|
|
{
|
|
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(@"D:\game\Assets\ui\Assets\icon.png", @"D:\game", "ui/Assets/icon.png")]
|
|
[InlineData(@"D:\game\Assets\icon.png", @"D:\game\", "icon.png")]
|
|
[InlineData(@"D:\game\Other\icon.png", @"D:\game", null)]
|
|
public void ToAssetPath_WithProjectDir_RootsAtProjectAssetsFolder(
|
|
string fullPath, string projectDir, string? expected)
|
|
{
|
|
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath, projectDir));
|
|
}
|
|
|
|
[Fact]
|
|
public void SpecialCharactersInNames_AreEscapedInDocsAndLiterals()
|
|
{
|
|
var source = RunGenerator([@"D:\game\Assets\UI\a&b <c>.png"]);
|
|
|
|
Assert.Contains("a&b <c>", source); // XML-док экранирован (иначе CS1570)
|
|
Assert.Contains("new(\"UI/a&b <c>.png\")", source);
|
|
}
|
|
|
|
[Fact]
|
|
public void FileNamedLikeContainerClass_DoesNotCollide()
|
|
{
|
|
// Член с именем вмещающего класса — ошибка CS0542; генератор должен переименовать.
|
|
var source = RunGenerator([@"D:\game\Assets\GameAssets.png"]);
|
|
|
|
Assert.Contains("GameAssets2 = new(\"GameAssets.png\")", source);
|
|
Assert.DoesNotContain("GameAssets = new(", source);
|
|
}
|
|
}
|