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:
co-authored by
Claude Fable 5
parent
56f2a85478
commit
501d81e19f
@@ -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&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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user