test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated)
PakEquivalenceTests runs MeshExtractor LIVE and bakes the SAME fixture ids to a temp pak, reads it back via PakReader, and deep-compares every field via the Task 3 ObjectMeshDataEquality comparator. Since the bake tool and the live client drive the identical MeshExtractor code (MP1a), this proves the pak ROUND-TRIP preserves what extraction actually produces on real content — the serializer's job, not a re-verification of the extraction algorithm itself (the existing Conformance suite owns that). Fixture set (>= the plan's minimums): 10 GfxObjs (3 known-tricky ids reused from Issue119UpNullGfxObjDumpTests - 0x010002B4, 0x010008A8, 0x010014C3 - plus 7 more from dat order), 3 Setups reused from door/ tower conformance fixtures (0x020019FF, 0x020005D8, 0x020003F2), 5 EnvCells walked from the Holtburg landblock 0xA9B40000's LandBlockInfo.NumCells range (same idiom as StipplingSurfaceEquivalenceTests). Skips cleanly when dats are absent via ContentConformanceDats. ResolveDatDir(), a deliberate small duplicate of ConformanceDats.ResolveDatDir()'s exact pattern (env var then Documents/Asheron's Call fallback) since Content.Tests cannot reference the AcDream.Core.Tests project. ContentTestDatCollectionAdapter is a third small copy of the IDatReaderWriter dat-access glue (alongside AcDream.App's internal original and AcDream.Bake's copy) for the same layering reason (structure rule 6: tests live in the project matching the layer under test). Ran for real against the dats on this machine (not just CI-skip path): 1 test, all fixture ids extracted live with zero failures, baked, read back, and field-for-field identical. Full solution dotnet test: 4106 tests total (43 Content.Tests + 385 Core.Net.Tests + 425 UI.Abstractions.Tests + 722 App.Tests incl. 2 pre-existing skips + 2531 Core.Tests incl. 2 pre-existing skips) — 0 failures.
This commit is contained in:
parent
50f7dd06cf
commit
55f16a0205
4 changed files with 339 additions and 0 deletions
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
|
|
|||
38
tests/AcDream.Content.Tests/ContentConformanceDats.cs
Normal file
38
tests/AcDream.Content.Tests/ContentConformanceDats.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Dat-dir resolution for the Content.Tests dat-gated equivalence suite.
|
||||
/// Mirrors the exact pattern used by
|
||||
/// tests/AcDream.Core.Tests/Conformance/ConformanceDats.ResolveDatDir and
|
||||
/// tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests
|
||||
/// ("dats absent (CI) -- skip, matching suite convention"). Content.Tests
|
||||
/// cannot reference AcDream.Core.Tests (a test project, not a library), so
|
||||
/// this is a deliberate small duplication of the resolution logic — not a
|
||||
/// new convention.
|
||||
/// </summary>
|
||||
public static class ContentConformanceDats {
|
||||
public static string? ResolveDatDir() {
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
|
||||
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal ILogger writing to Console — surfaces MeshExtractor failures during the equivalence sweep instead of silently swallowing them (NullLogger would hide the exact failure this suite exists to catch).</summary>
|
||||
internal sealed class TestConsoleLogger : ILogger {
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
|
||||
if (!IsEnabled(logLevel)) return;
|
||||
Console.WriteLine($"[{logLevel}] MeshExtractor: {formatter(state, exception)}");
|
||||
if (exception is not null) Console.WriteLine(exception);
|
||||
}
|
||||
}
|
||||
145
tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs
Normal file
145
tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts <see cref="DatCollection"/> to <see cref="IDatReaderWriter"/> for
|
||||
/// MeshExtractor's constructor, scoped to the equivalence test suite. A
|
||||
/// third copy alongside AcDream.App.Rendering.Wb.DatCollectionAdapter
|
||||
/// (internal to AcDream.App) and AcDream.Bake.BakeDatCollectionAdapter
|
||||
/// (internal to AcDream.Bake) — tests live in the project matching the
|
||||
/// layer under test (structure rule 6), so Content.Tests cannot reference
|
||||
/// AcDream.Bake just to borrow its copy. Plain dat-access glue, not an
|
||||
/// AC-specific algorithm.
|
||||
/// </summary>
|
||||
internal sealed class ContentTestDatCollectionAdapter : IDatReaderWriter {
|
||||
private readonly DatCollection _dats;
|
||||
private readonly TestDatDatabaseWrapper _portal;
|
||||
private readonly TestDatDatabaseWrapper _cell;
|
||||
private readonly TestDatDatabaseWrapper _highRes;
|
||||
private readonly TestDatDatabaseWrapper _language;
|
||||
private readonly ReadOnlyDictionary<uint, IDatDatabase> _cellRegions;
|
||||
|
||||
public ContentTestDatCollectionAdapter(DatCollection dats) {
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
_dats = dats;
|
||||
_portal = new TestDatDatabaseWrapper(dats.Portal);
|
||||
_cell = new TestDatDatabaseWrapper(dats.Cell);
|
||||
_highRes = new TestDatDatabaseWrapper(dats.HighRes);
|
||||
_language = new TestDatDatabaseWrapper(dats.Local);
|
||||
|
||||
var regions = new Dictionary<uint, IDatDatabase> { [0u] = _cell };
|
||||
_cellRegions = new ReadOnlyDictionary<uint, IDatDatabase>(regions);
|
||||
}
|
||||
|
||||
public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty;
|
||||
|
||||
public IDatDatabase Portal => _portal;
|
||||
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _cellRegions;
|
||||
public IDatDatabase HighRes => _highRes;
|
||||
public IDatDatabase Language => _language;
|
||||
|
||||
public ReadOnlyDictionary<uint, uint> RegionFileMap =>
|
||||
new ReadOnlyDictionary<uint, uint>(new Dictionary<uint, uint>());
|
||||
|
||||
public int PortalIteration => _portal.Iteration;
|
||||
public int CellIteration => _cell.Iteration;
|
||||
public int HighResIteration => _highRes.Iteration;
|
||||
public int LanguageIteration => _language.Iteration;
|
||||
|
||||
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) {
|
||||
return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead);
|
||||
}
|
||||
|
||||
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) {
|
||||
var results = new List<IDatReaderWriter.IdResolution>();
|
||||
|
||||
void CheckDb(TestDatDatabaseWrapper wrapper) {
|
||||
var rawDb = wrapper.RawDatabase;
|
||||
if (rawDb.Tree.TryGetFile(id, out _)) {
|
||||
var type = rawDb.TypeFromId(id);
|
||||
if (type != DBObjType.Unknown) {
|
||||
results.Add(new IDatReaderWriter.IdResolution(wrapper, type));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CheckDb(_highRes);
|
||||
CheckDb(_portal);
|
||||
CheckDb(_language);
|
||||
CheckDb(_cell);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException($"{nameof(ContentTestDatCollectionAdapter)} is read-only.");
|
||||
|
||||
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException($"{nameof(ContentTestDatCollectionAdapter)} is read-only.");
|
||||
|
||||
public void Dispose() {
|
||||
// The underlying DatCollection is owned by the caller.
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestDatDatabaseWrapper : IDatDatabase {
|
||||
private readonly DatDatabase _db;
|
||||
private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public TestDatDatabaseWrapper(DatDatabase db) {
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
_db = db;
|
||||
}
|
||||
|
||||
internal DatDatabase RawDatabase => _db;
|
||||
|
||||
public DatDatabase Db => _db;
|
||||
public int Iteration => _db.Iteration?.CurrentIteration ?? 0;
|
||||
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _db.GetAllIdsOfType<T>();
|
||||
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj {
|
||||
if (_cache.TryGetValue((typeof(T), fileId), out var cached)) {
|
||||
value = (T)cached;
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_lock) {
|
||||
if (_db.TryGet<T>(fileId, out value)) {
|
||||
_cache.TryAdd((typeof(T), fileId), value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) {
|
||||
lock (_lock) {
|
||||
return _db.TryGetFileBytes(fileId, out value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) {
|
||||
lock (_lock) {
|
||||
return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException($"{nameof(TestDatDatabaseWrapper)} is read-only.");
|
||||
|
||||
public void Dispose() {
|
||||
// The underlying DatDatabase is owned by DatCollection.
|
||||
}
|
||||
}
|
||||
155
tests/AcDream.Content.Tests/PakEquivalenceTests.cs
Normal file
155
tests/AcDream.Content.Tests/PakEquivalenceTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.Content.Pak;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.Content.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// MP1b Task 6: dat-gated live-vs-pak equivalence suite. Runs MeshExtractor
|
||||
/// LIVE and bakes the SAME ids to a temp pak, reads the pak back, and deep-
|
||||
/// compares field-by-field via the Task 3 comparator. Because the bake tool
|
||||
/// and the live client drive the identical MeshExtractor code (MP1a), this
|
||||
/// test is proving the pak ROUND-TRIP (serialize/deserialize) preserves
|
||||
/// what extraction actually produces on real content — not re-verifying the
|
||||
/// extraction algorithm itself (that's the existing Conformance suite's job).
|
||||
///
|
||||
/// Skips cleanly when the real dats are absent (CI), matching
|
||||
/// DatConcurrencyStressTests' convention.
|
||||
/// </summary>
|
||||
public sealed class PakEquivalenceTests {
|
||||
// Known-tricky GfxObj ids reused from existing conformance fixtures:
|
||||
// 0x010002B4 / 0x010008A8 — #119 "[up-null] upload returned null" dump
|
||||
// targets (Issue119UpNullGfxObjDumpTests).
|
||||
// 0x010014C3 — the #113-saga Holtburg meeting-hall shell
|
||||
// (Issue119UpNullGfxObjDumpTests.ShellModel_NoTexturedPolyIsDropped).
|
||||
private static readonly uint[] KnownTrickyGfxObjIds = { 0x010002B4u, 0x010008A8u, 0x010014C3u };
|
||||
|
||||
// Setup ids reused from existing physics/conformance fixtures:
|
||||
// 0x020019FF — the door setup (DoorBugTrajectoryReplayTests,
|
||||
// DoorSetupGfxObjInspectionTests, DoorCollisionApparatusTests).
|
||||
// 0x020005D8 / 0x020003F2 — Issue119TowerDumpTests fixtures.
|
||||
private static readonly uint[] SetupIds = { 0x020019FFu, 0x020005D8u, 0x020003F2u };
|
||||
|
||||
private const uint HoltburgLandblock = 0xA9B40000u; // ConformanceDats.HoltburgLandblock
|
||||
|
||||
[Fact]
|
||||
public void LiveExtraction_MatchesPakRoundTrip_OnFixtureIdSet() {
|
||||
var datDir = ContentConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return; // dats absent (CI) — skip, matching suite convention
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
using var datReaderWriter = new ContentTestDatCollectionAdapter(dats);
|
||||
var logger = new TestConsoleLogger();
|
||||
|
||||
var sideStaged = new List<ObjectMeshData>();
|
||||
var extractor = new MeshExtractor(datReaderWriter, logger, data => sideStaged.Add(data));
|
||||
|
||||
var gfxObjIds = BuildGfxObjIdSet(dats);
|
||||
var setupIds = SetupIds.ToList();
|
||||
var envCellIds = BuildEnvCellIdSet(dats, HoltburgLandblock, minCount: 5);
|
||||
|
||||
Assert.True(gfxObjIds.Count >= 10, $"fixture GfxObj id set unexpectedly small ({gfxObjIds.Count})");
|
||||
Assert.True(setupIds.Count >= 3, $"fixture Setup id set unexpectedly small ({setupIds.Count})");
|
||||
Assert.True(envCellIds.Count >= 5, $"fixture EnvCell id set unexpectedly small ({envCellIds.Count})");
|
||||
|
||||
var work = new List<(PakAssetType Type, uint FileId, ulong ExtractorId, bool IsSetup)>();
|
||||
work.AddRange(gfxObjIds.Select(id => (PakAssetType.GfxObjMesh, id, (ulong)id, false)));
|
||||
work.AddRange(setupIds.Select(id => (PakAssetType.SetupMesh, id, (ulong)id, true)));
|
||||
work.AddRange(envCellIds.Select(id => (PakAssetType.EnvCellMesh, id, id | 0x1_0000_0000UL, true)));
|
||||
|
||||
// ---- LIVE extraction (golden) ----
|
||||
var golden = new Dictionary<(PakAssetType, uint), ObjectMeshData>();
|
||||
var extractionFailures = new List<string>();
|
||||
foreach (var (type, fileId, extractorId, isSetup) in work) {
|
||||
var data = extractor.PrepareMeshData(extractorId, isSetup);
|
||||
if (data is null) {
|
||||
extractionFailures.Add($"{type} 0x{fileId:X8}: live extraction returned null");
|
||||
continue;
|
||||
}
|
||||
golden[(type, fileId)] = data;
|
||||
}
|
||||
|
||||
Assert.True(extractionFailures.Count == 0,
|
||||
$"{extractionFailures.Count} fixture ids failed LIVE extraction (fixture assumption broke): " +
|
||||
string.Join(" | ", extractionFailures));
|
||||
|
||||
// ---- bake the SAME ids to a temp pak ----
|
||||
var pakPath = Path.Combine(Path.GetTempPath(), $"acdream-equivtest-{System.Guid.NewGuid():N}.pak");
|
||||
try {
|
||||
var header = new PakHeader {
|
||||
FormatVersion = 1,
|
||||
PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration,
|
||||
CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration,
|
||||
HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration,
|
||||
LanguageIteration = (uint)dats.Local.Iteration!.CurrentIteration,
|
||||
BakeToolVersion = 1,
|
||||
};
|
||||
using (var writer = new PakWriter(pakPath, header)) {
|
||||
foreach (var ((type, fileId), data) in golden) {
|
||||
writer.AddBlob(PakKey.Compose(type, fileId), data);
|
||||
}
|
||||
writer.Finish();
|
||||
}
|
||||
|
||||
// ---- read back and deep-compare ----
|
||||
using var reader = new PakReader(pakPath);
|
||||
var mismatches = new List<string>();
|
||||
foreach (var ((type, fileId), expected) in golden) {
|
||||
var key = PakKey.Compose(type, fileId);
|
||||
if (!reader.TryReadObjectMeshData(key, out var actual)) {
|
||||
mismatches.Add($"{type} 0x{fileId:X8}: pak read failed (missing or CRC mismatch)");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ObjectMeshDataEquality.AssertEqual(expected, actual);
|
||||
}
|
||||
catch (Xunit.Sdk.XunitException ex) {
|
||||
mismatches.Add($"{type} 0x{fileId:X8}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0,
|
||||
$"{mismatches.Count}/{golden.Count} fixture ids mismatched between live extraction and pak round-trip:\n" +
|
||||
string.Join("\n", mismatches));
|
||||
}
|
||||
finally {
|
||||
if (File.Exists(pakPath)) File.Delete(pakPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known-tricky ids (#119/#113 dump fixtures) plus enough additional
|
||||
/// GfxObjs (deterministically, the first N ids in dat order after the
|
||||
/// tricky set) to reach at least 10 total.
|
||||
/// </summary>
|
||||
private static List<uint> BuildGfxObjIdSet(DatCollection dats) {
|
||||
var ids = new List<uint>(KnownTrickyGfxObjIds);
|
||||
var seen = new HashSet<uint>(ids);
|
||||
|
||||
foreach (var id in dats.GetAllIdsOfType<GfxObj>()) {
|
||||
if (ids.Count >= 10) break;
|
||||
if (seen.Add(id)) ids.Add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>Walks the Holtburg landblock's LandBlockInfo.NumCells range (mirrors StipplingSurfaceEquivalenceTests' enumeration) to get at least <paramref name="minCount"/> real EnvCell ids.</summary>
|
||||
private static List<uint> BuildEnvCellIdSet(DatCollection dats, uint landblockId, int minCount) {
|
||||
var ids = new List<uint>();
|
||||
var lbInfo = dats.Get<LandBlockInfo>(landblockId | 0xFFFEu);
|
||||
Assert.True(lbInfo is not null, $"LandBlockInfo for landblock 0x{landblockId:X8} not found — fixture assumption broke");
|
||||
Assert.True(lbInfo!.NumCells >= minCount,
|
||||
$"landblock 0x{landblockId:X8} has only {lbInfo.NumCells} cells — fixture assumption broke (need >= {minCount})");
|
||||
|
||||
uint firstCellId = landblockId | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells && ids.Count < minCount; offset++) {
|
||||
uint envCellId = firstCellId + offset;
|
||||
if (dats.Get<EnvCell>(envCellId) is not null) ids.Add(envCellId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue