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>
233 lines
8.5 KiB
C#
233 lines
8.5 KiB
C#
using StbImageSharp;
|
||
using StbImageWriteSharp;
|
||
using Xunit;
|
||
|
||
namespace MrGameEng.Atlases.Tests;
|
||
|
||
public sealed class AtlasBuilderTests : IDisposable
|
||
{
|
||
private readonly string _root = Directory.CreateTempSubdirectory("mrge-atlas-tests-").FullName;
|
||
|
||
private string SourceDir => Path.Combine(_root, "Textures");
|
||
private string OutputDir => Path.Combine(_root, "Atlases");
|
||
|
||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||
|
||
/// <summary>Writes a PNG filled with one RGBA color.</summary>
|
||
private void WritePng(string relativePath, int width, int height, byte r, byte g, byte b, byte a = 255)
|
||
{
|
||
var fullPath = Path.Combine(SourceDir, relativePath);
|
||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||
var data = new byte[width * height * 4];
|
||
for (var i = 0; i < data.Length; i += 4)
|
||
{
|
||
data[i] = r;
|
||
data[i + 1] = g;
|
||
data[i + 2] = b;
|
||
data[i + 3] = a;
|
||
}
|
||
|
||
using var stream = File.Create(fullPath);
|
||
new ImageWriter().WritePng(data, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
|
||
}
|
||
|
||
private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) => new()
|
||
{
|
||
SourceDirectory = SourceDir,
|
||
OutputDirectory = OutputDir,
|
||
GroupDepth = groupDepth,
|
||
MaxPageSize = 128,
|
||
Padding = 2,
|
||
Force = force,
|
||
};
|
||
|
||
[Theory]
|
||
[InlineData("Terrain/Surfaces/Marsh.png", 1, "Terrain", "Terrain/Surfaces/Marsh")]
|
||
[InlineData("Terrain/Surfaces/Marsh.png", 2, "Terrain.Surfaces", "Terrain/Surfaces/Marsh")]
|
||
[InlineData("Terrain/Surfaces/Marsh.png", 0, "Root", "Terrain/Surfaces/Marsh")]
|
||
[InlineData("loose.png", 3, "Root", "loose")]
|
||
public void ClassifyPath_GroupsByDepth(string path, int depth, string expectedAtlas, string expectedKey)
|
||
{
|
||
var (atlas, key) = AtlasBuilder.ClassifyPath(path, depth, "Root");
|
||
|
||
Assert.Equal(expectedAtlas, atlas);
|
||
Assert.Equal(expectedKey, key);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_WritesMetadataAndPages_PixelsSurviveRoundtrip()
|
||
{
|
||
WritePng("Terrain/Grass.png", 16, 16, 10, 200, 30);
|
||
WritePng("Terrain/Water.png", 16, 8, 30, 40, 250);
|
||
|
||
var result = AtlasBuilder.Build(Options());
|
||
|
||
var group = Assert.Single(result.Groups);
|
||
Assert.Equal("Terrain", group.Name);
|
||
Assert.Equal(2, group.RegionCount);
|
||
Assert.False(group.Skipped);
|
||
|
||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas")));
|
||
Assert.Equal(["Terrain/Grass", "Terrain/Water"], metadata.Regions.Select(x => x.Key));
|
||
|
||
var grass = metadata.Regions.Single(x => x.Key == "Terrain/Grass");
|
||
var page = metadata.Pages[grass.Page];
|
||
using var stream = File.OpenRead(Path.Combine(OutputDir, page.File));
|
||
var pixels = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
|
||
|
||
// Центральный пиксель региона должен быть цветом исходной картинки.
|
||
var center = ((grass.Y + 8) * pixels.Width + grass.X + 8) * 4;
|
||
Assert.Equal((byte)10, pixels.Data[center]);
|
||
Assert.Equal((byte)200, pixels.Data[center + 1]);
|
||
Assert.Equal((byte)30, pixels.Data[center + 2]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_SecondRunWithoutChanges_SkipsGroup()
|
||
{
|
||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||
|
||
var first = AtlasBuilder.Build(Options());
|
||
var second = AtlasBuilder.Build(Options());
|
||
|
||
Assert.False(Assert.Single(first.Groups).Skipped);
|
||
Assert.True(Assert.Single(second.Groups).Skipped);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_ChangedSource_Rebuilds()
|
||
{
|
||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||
AtlasBuilder.Build(Options());
|
||
|
||
File.SetLastWriteTimeUtc(
|
||
Path.Combine(SourceDir, "UI/button.png"), DateTime.UtcNow.AddMinutes(1));
|
||
var result = AtlasBuilder.Build(Options());
|
||
|
||
Assert.False(Assert.Single(result.Groups).Skipped);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_AddedFile_RebuildsGroup()
|
||
{
|
||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||
AtlasBuilder.Build(Options());
|
||
|
||
WritePng("UI/icon.png", 8, 8, 4, 5, 6);
|
||
var result = AtlasBuilder.Build(Options());
|
||
|
||
var group = Assert.Single(result.Groups);
|
||
Assert.False(group.Skipped);
|
||
Assert.Equal(2, group.RegionCount);
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_RemovedGroup_DeletesOrphanedAtlas()
|
||
{
|
||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||
WritePng("World/rock.png", 8, 8, 7, 8, 9);
|
||
AtlasBuilder.Build(Options());
|
||
|
||
Directory.Delete(Path.Combine(SourceDir, "World"), recursive: true);
|
||
var result = AtlasBuilder.Build(Options());
|
||
|
||
Assert.NotEmpty(result.DeletedOrphans);
|
||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas")));
|
||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas.0.png")));
|
||
Assert.True(File.Exists(Path.Combine(OutputDir, "UI.atlas")));
|
||
}
|
||
|
||
[Fact]
|
||
public void Build_ManyImages_SpillToMultiplePages()
|
||
{
|
||
for (var i = 0; i < 12; i++)
|
||
{
|
||
WritePng($"Things/sprite{i:D2}.png", 60, 60, (byte)i, 0, 0);
|
||
}
|
||
|
||
var result = AtlasBuilder.Build(Options());
|
||
|
||
// 60² с padding 2 на страницу 128² помещаются по 4 — минимум 3 страницы.
|
||
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()
|
||
{
|
||
var metadata = new AtlasMetadata
|
||
{
|
||
Name = "Things.Pawn",
|
||
PageSize = 2048,
|
||
Padding = 2,
|
||
Pages = [new AtlasPage { File = "Things.Pawn.atlas.0.png", Width = 256, Height = 128 }],
|
||
Regions = [new AtlasRegion { Key = "Things/Pawn/Fox", Page = 0, X = 2, Y = 4, Width = 64, Height = 32 }],
|
||
};
|
||
|
||
var parsed = AtlasMetadata.FromJson(metadata.ToJson());
|
||
|
||
Assert.Equal("Things.Pawn", parsed.Name);
|
||
Assert.Equal(2048, parsed.PageSize);
|
||
var page = Assert.Single(parsed.Pages);
|
||
Assert.Equal(("Things.Pawn.atlas.0.png", 256, 128), (page.File, page.Width, page.Height));
|
||
var region = Assert.Single(parsed.Regions);
|
||
Assert.Equal(("Things/Pawn/Fox", 0, 2, 4, 64, 32),
|
||
(region.Key, region.Page, region.X, region.Y, region.Width, region.Height));
|
||
}
|
||
|
||
[Fact]
|
||
public void TextureAtlas_LooksUpRegions_FromMetadata()
|
||
{
|
||
var metadata = new AtlasMetadata
|
||
{
|
||
Name = "Test",
|
||
Pages = [new AtlasPage { File = "Test.atlas.0.png", Width = 64, Height = 64 }],
|
||
Regions =
|
||
[
|
||
new AtlasRegion { Key = "a/b", Page = 0, X = 2, Y = 2, Width = 10, Height = 12 },
|
||
],
|
||
};
|
||
|
||
// Texture2D == null допустим в headless-тестах (см. Texture2DRegion).
|
||
var atlas = new TextureAtlas(metadata, new Microsoft.Xna.Framework.Graphics.Texture2D[1]);
|
||
|
||
var region = atlas.GetRegion("a/b");
|
||
Assert.Equal(new Microsoft.Xna.Framework.Rectangle(2, 2, 10, 12), region.Bounds);
|
||
Assert.True(atlas.TryGetRegion("a/b", out _));
|
||
Assert.False(atlas.TryGetRegion("missing", out _));
|
||
Assert.Throws<KeyNotFoundException>(() => atlas.GetRegion("missing"));
|
||
}
|
||
}
|