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); /// Writes a PNG filled with one RGBA color. 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(() => atlas.GetRegion("missing")); } }