Fix engine-wide code review findings
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>
This commit is contained in:
Leonid Pershin
2026-06-11 21:18:08 +03:00
co-authored by Claude Fable 5
parent 56f2a85478
commit 501d81e19f
35 changed files with 926 additions and 98 deletions
@@ -156,4 +156,33 @@ public class AssetHandlesGeneratorTests
{
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&amp;b &lt;c&gt;", 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);
}
}
@@ -151,6 +151,39 @@ public sealed class AtlasBuilderTests : IDisposable
Assert.True(Assert.Single(result.Groups).PageCount >= 3);
}
[Fact]
public void Build_ContentChangedWithSameTimestamp_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
// Копия «с сохранением времени» (robocopy, zip): mtime прежний, контент другой.
var path = Path.Combine(SourceDir, "UI/button.png");
var timestamp = File.GetLastWriteTimeUtc(path);
WritePng("UI/button.png", 16, 16, 9, 9, 9);
File.SetLastWriteTimeUtc(path, timestamp);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Build_OldMetadataVersion_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
var metadataPath = Path.Combine(OutputDir, "UI.atlas");
var json = File.ReadAllText(metadataPath)
.Replace($"\"version\": {AtlasMetadata.CurrentVersion}", "\"version\": 1");
File.WriteAllText(metadataPath, json);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Metadata_JsonRoundtrip_PreservesEverything()
{
@@ -108,4 +108,41 @@ public class ShelfPackerTests
{
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
}
[Fact]
public void Pack_NonPowerOfTwoLimit_SharedPagesNeverExceedIt()
{
// 40² с padding на лимит 100: POT-округление дало бы 128 > лимита.
var result = ShelfPacker.Pack(Squares(10, 40), maxPageSize: 100, padding: 2);
Assert.All(result.PageSizes, size =>
{
Assert.True(size.Width <= 100, $"page width {size.Width} > 100");
Assert.True(size.Height <= 100, $"page height {size.Height} > 100");
});
}
[Fact]
public void Pack_OversizedItem_PageHasExactPaddedSize()
{
var result = ShelfPacker.Pack([new PackItem("big", 300, 50)], maxPageSize: 128, padding: 2);
Assert.Equal((304, 54), result.PageSizes.Single());
}
[Fact]
public void Pack_OversizedAmongSmall_DoesNotSplitSharedPage()
{
// Широкий и низкий негабарит сортируется в конец по высоте; раньше он закрывал
// наполовину заполненную общую страницу, и остаток уезжал на новую.
var items = new List<PackItem> { new("big", 300, 10) };
for (var i = 0; i < 4; i++)
{
items.Add(new PackItem($"s{i}", 30, 30));
}
var result = ShelfPacker.Pack(items, maxPageSize: 128, padding: 2);
Assert.Equal(2, result.PageSizes.Count); // отдельная страница big + одна общая
}
}
@@ -182,4 +182,59 @@ public class CollisionWorldTests
Assert.False(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out _));
}
[Fact]
public void QueryAabb_BeforeFirstRebuild_ReturnsZero()
{
// До первого EndRebuild хэш пуст; запрос не должен зависать и что-то находить.
var world = new CollisionWorld();
Span<Entity> results = new Entity[4];
Assert.Equal(0, world.QueryAabb(new RectF(-10f, -10f, 20f, 20f), results));
}
[Fact]
public void QueryAabb_TruncatedResults_DoNotPoisonNextQuery()
{
var (context, scene) = CreateScene();
for (var i = 0; i < 3; i++)
{
Spawn(scene, new Vector2(i * 5f, 0f), Collider.Circle(4f));
}
Tick(context);
var area = new RectF(-10f, -10f, 40f, 20f);
Span<Entity> tiny = new Entity[1];
Assert.Equal(1, scene.World.QueryAabb(area, tiny)); // обрезан по размеру буфера
Span<Entity> all = new Entity[8];
Assert.Equal(3, scene.World.QueryAabb(area, all)); // повторный запрос видит всех
}
[Fact]
public void Raycast_FromInsideCircle_HitsAtOrigin()
{
var (context, scene) = CreateScene();
var entity = Spawn(scene, new Vector2(0f, 0f), Collider.Circle(10f));
Tick(context);
Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out var hit));
Assert.Equal(entity, hit.Entity);
Assert.Equal(0f, hit.Fraction);
Assert.Equal(Vector2.Zero, hit.Point);
}
[Fact]
public void ExactlyTouchingBoxes_ProducePair()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Box(20f, 20f));
Spawn(scene, new Vector2(20f, 0f), Collider.Box(20f, 20f)); // грани соприкасаются ровно
Tick(context);
Assert.Equal(1, scene.World.Pairs.Length); // как и у касающихся кругов
}
}
@@ -84,4 +84,69 @@ public class SceneManagerTests
Assert.Null(context.Scenes.Current);
}
private sealed class CallbackScene(Action<Scene> onLoad) : Scene
{
protected override void OnLoad() => onLoad(this);
}
[Fact]
public void Switch_BackToLoadedSceneInstance_Throws()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
context.Scenes.Update(context.Clock);
context.Scenes.Switch(second);
context.Scenes.Update(context.Clock);
// Повторная загрузка молча задвоила бы системы и сущности — должен быть отказ.
context.Scenes.Switch(first);
Assert.Throws<InvalidOperationException>(() => context.Scenes.Update(context.Clock));
}
[Fact]
public void RegisterUnload_RunsOnUnload_InReverseOrder()
{
var context = new EngineContext();
var order = new List<int>();
var scene = new CallbackScene(s =>
{
s.RegisterUnload(() => order.Add(1));
s.RegisterUnload(() => order.Add(2));
});
context.Scenes.Switch(scene);
context.Scenes.Update(context.Clock);
Assert.Empty(order);
context.Scenes.Switch(null);
context.Scenes.Update(context.Clock);
Assert.Equal([2, 1], order);
}
private sealed class DisposableService : IDisposable
{
public int DisposeCount;
public void Dispose() => DisposeCount++;
}
[Fact]
public void DisposeServices_DisposesEachServiceOnce_AndSkipsExcept()
{
var registry = new ServiceRegistry();
var service = new DisposableService();
var host = new DisposableService();
registry.Add(service);
registry.Add<IDisposable>(host); // host зарегистрирован, но его освобождает вызывающий
registry.DisposeServices(except: host);
Assert.Equal(1, service.DisposeCount);
Assert.Equal(0, host.DisposeCount);
Assert.Null(registry.GetOrDefault<DisposableService>());
}
}
@@ -89,6 +89,32 @@ public class SceneTransitionTests
Assert.Equal(0, second.LoadCount);
}
[Fact]
public void SwitchDuringReveal_CoversAgain_InsteadOfHardSwap()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
var third = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
Tick(context, 0.6f); // закрыто, своп на second, началось открытие
Assert.Same(second, context.Scenes.Current);
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
Assert.Same(second, context.Scenes.Current);
Assert.True(context.Scenes.IsTransitioning);
Tick(context, 0.6f); // полностью закрыт — теперь своп на third
Assert.Same(third, context.Scenes.Current);
Assert.Equal(1, third.LoadCount);
}
[Fact]
public void ZeroDurationTransition_SwapsOnNextUpdates()
{
@@ -131,6 +131,49 @@ public class DevConsoleTests
Assert.Contains("[warn] careful", visible);
}
[Fact]
public void Execute_QuotedArguments_KeptAsSingleArgument()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("say", "", (_, args) => received = args);
console.Execute("say \"hello world\" plain \"\"");
Assert.Equal(["hello world", "plain", ""], received!);
}
[Fact]
public void Execute_UnterminatedQuote_RunsToEndOfLine()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("say", "", (_, args) => received = args);
console.Execute("say \"one two");
Assert.Equal(["one two"], received!);
}
[Fact]
public void History_IsCapped_OldestEntriesDropped()
{
using var console = new DevConsole();
console.Register("n", "", (_, _) => { });
for (var i = 0; i < 300; i++)
{
console.Execute($"n {i}");
}
string? oldest = null;
for (var i = 0; i < 400; i++)
{
oldest = console.HistoryPrevious(); // упирается в самую старую сохранённую
}
Assert.Equal("n 44", oldest); // 300 256 (лимит) = 44
}
[Fact]
public void Revision_ChangesOnlyOnVisibleChanges()
{
@@ -40,6 +40,28 @@ public class InputManagerTests
Assert.False(input.IsKeyReleased(Keys.A));
}
[Fact]
public void Capture_SuppressesInput_KeepsMousePositionAndWheel()
{
var capture = new MrGameEng.Core.InputCapture();
var input = new InputManager(capture);
Frame(input, new KeyboardState(Keys.W), Mouse(x: 10, y: 20, wheel: 120));
Assert.True(input.IsKeyDown(Keys.W));
capture.Captured = true;
input.Update(); // ввод захвачен оверлеем — устройства не опрашиваются
Assert.False(input.IsKeyDown(Keys.W));
Assert.True(input.IsKeyReleased(Keys.W)); // одно корректное событие отпускания
Assert.Equal(new Point(10, 20), input.MousePosition);
Assert.Equal(0, input.WheelDelta);
Assert.Equal(Point.Zero, input.MouseDelta);
input.Update();
Assert.False(input.IsKeyReleased(Keys.W)); // и больше никаких фантомных событий
}
[Fact]
public void MouseDeltaAndWheelDelta_ComputedBetweenFrames()
{
@@ -0,0 +1,44 @@
using Microsoft.Xna.Framework;
using Xunit;
namespace MrGameEng.Pathfinding.Tests;
/// <summary>
/// Размеры грида фиксируются в конструкторе; «уехавший» грид должен давать
/// громкую ошибку, а не молчаливое чтение мимо границ.
/// </summary>
public class GridResizeTests
{
private sealed class ResizableGrid : IPathGrid
{
public int Width { get; set; } = 4;
public int Height { get; set; } = 4;
public bool IsPassable(int x, int y) => true;
public float Cost(int x, int y) => 1f;
}
[Fact]
public void FindPath_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var pathfinder = new GridPathfinder(grid);
grid.Width = 8;
Assert.Throws<InvalidOperationException>(
() => pathfinder.FindPath(new Point(0, 0), new Point(1, 1), []));
}
[Fact]
public void FlowFieldBuild_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var builder = new FlowFieldBuilder(grid);
grid.Height = 8;
Assert.Throws<InvalidOperationException>(
() => builder.Build([new Point(0, 0)], new FlowField()));
}
}