test(uocontent): skip tile-data-dependent tests when client files absent

Now that CI actually runs the test projects (previous commits), it exposed
that the UOContent.Tests bootstrap force-loaded UO client tile data
(tiledata.mul) unconditionally in the collection-fixture constructor. On
CI the copyrighted client files are absent, so TileData.Load() threw and
xUnit failed every test in the collection with the same
FileNotFoundException - 337 failures, most of them collateral (packet,
scheduler, spawner tests that don't need tile data at all).

Mirror the graceful pattern Server.Tests already uses:

- TestServerInitializer now probes for tiledata.mul (Core.FindDataFile +
  File.Exists) and only force-loads tile data / runs the tile-dependent
  configure steps (MultiData.Configure, VerifyTrammelTileDataLoaded) when
  present. It exposes TileDataLoaded so the fixture constructor no longer
  throws when the files are missing.
- Add a shared TileDataRequirement.SkipIfMissing() guard and apply it to
  exactly the 31 pathfinding/multi/AI tests that genuinely require real
  tile/multi data, converting them to [SkippableFact]/[SkippableTheory].

Result: with client data absent (CI) the suite is 0 failed / 32 skipped /
469 passed; with data present (dev) it is 0 failed / 0 skipped / 501
passed. The 306 collateral failures are gone because the fixture no longer
throws.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-07-02 22:32:25 -07:00
parent 3f305f9da7
commit 8613f7d480
17 changed files with 112 additions and 39 deletions

View file

@ -30,6 +30,13 @@ internal static class TestServerInitializer
private static bool _initialized;
private static readonly Lock _lock = new();
/// <summary>
/// True if the UO client tile data was found and loaded. When false (e.g. CI, where the
/// copyrighted client files are absent), tile/map/multi-dependent tests must skip rather than
/// fail. Guard such tests with <c>Skip.If(!TestServerInitializer.TileDataLoaded, ...)</c>.
/// </summary>
public static bool TileDataLoaded { get; private set; }
public static void Initialize()
{
lock (_lock)
@ -62,7 +69,10 @@ internal static class TestServerInitializer
// flags are populated before anything that reads TileData (MultiData, MovementImpl,
// CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure()
// time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y].
ForceLoadTileData();
// The copyrighted client files are absent on CI; when tiledata.mul is missing we skip
// the tile/map/multi-dependent bootstrap and leave TileDataLoaded false so those tests
// skip instead of failing the whole collection from the fixture constructor.
TileDataLoaded = TryForceLoadTileData();
// Production runs every static Configure() via AssemblyHandler.Invoke("Configure");
// the fixture calls a curated subset, so configure the pathfinding singleton here so
@ -70,11 +80,15 @@ internal static class TestServerInitializer
// calls Find. ServerConfiguration is already loaded above, so the setting resolves.
BitmapAStarAlgorithm.Configure();
// Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via
// AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that
// BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of
// MultiComponentList.Empty. Required by the Multi pathfinding tests.
MultiData.Configure();
if (TileDataLoaded)
{
// Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via
// AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that
// BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of
// MultiComponentList.Empty. Required by the Multi pathfinding tests. Depends on the
// client files, so it only runs when tile data loaded.
MultiData.Configure();
}
World.Configure();
Timer.Init(0);
@ -86,14 +100,23 @@ internal static class TestServerInitializer
DecayScheduler.Configure();
Server.Engines.Spawners.SpawnerJsonSerializer.Configure();
VerifyTrammelTileDataLoaded();
if (TileDataLoaded)
{
VerifyTrammelTileDataLoaded();
}
_initialized = true;
}
}
private static void ForceLoadTileData()
private static bool TryForceLoadTileData()
{
var tileDataPath = Core.FindDataFile("tiledata.mul", false);
if (string.IsNullOrEmpty(tileDataPath) || !File.Exists(tileDataPath))
{
return false;
}
var loadMethod = typeof(TileData).GetMethod(
"Load",
BindingFlags.Static | BindingFlags.NonPublic
@ -105,6 +128,7 @@ internal static class TestServerInitializer
);
}
loadMethod.Invoke(null, null);
return true;
}
private static void VerifyTrammelTileDataLoaded()

View file

@ -0,0 +1,18 @@
using Xunit;
namespace Server.Tests;
/// <summary>
/// Shared guard for tests that require the copyrighted UO client tile/map data (tiledata.mul and
/// friends), which is absent on CI. Call <see cref="SkipIfMissing"/> as the first statement of a
/// <c>[SkippableFact]</c>/<c>[SkippableTheory]</c> so the test is skipped — not failed — when the
/// data was not loaded. See <see cref="TestServerInitializer.TileDataLoaded"/>.
/// </summary>
internal static class TileDataRequirement
{
public static void SkipIfMissing() =>
Skip.If(
!TestServerInitializer.TileDataLoaded,
"Requires UO client tile data (tiledata.mul); absent on CI."
);
}

View file

@ -130,9 +130,10 @@ public class BitmapAStarAlgorithmTests
blocker.Delete();
}
[Fact]
[SkippableFact]
public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[1];
Assert.NotNull(map);

View file

@ -17,9 +17,10 @@ public class BoatPathTests
private const int WaterX = 1450;
private const int WaterY = 1770;
[Fact]
[SkippableFact]
public void BoatDeck_HasWalkableSurfaceCells()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
@ -44,9 +45,10 @@ public class BoatPathTests
}
}
[Fact]
[SkippableFact]
public void BoatDeck_FootprintShape_IsPositionInvariant()
{
TileDataRequirement.SkipIfMissing();
// The property Phase 2's local-frame, movement-invariant boat cache must preserve:
// the deck's covered-cell shape (in local coords) is identical at two world positions.
var map = Map.Maps[MapId];

View file

@ -17,9 +17,10 @@ public class FoundationRedesignTests
private const int PlaceX = 1500;
private const int PlaceY = 1600;
[Fact]
[SkippableFact]
public void SwappingComponents_ChangesFootprint()
{
TileDataRequirement.SkipIfMissing();
var foundation = new SwappableFoundation(0x74); // GuildHouse footprint
try
{
@ -38,9 +39,10 @@ public class FoundationRedesignTests
}
}
[Fact]
[SkippableFact]
public void RedesignReRegistered_RoutesNewFootprintToLivePath()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);

View file

@ -22,9 +22,10 @@ public class HousePathRoutingTests
public WalkerStub() => Body = 0xC9;
}
[Fact]
[SkippableFact]
public void PathAround_NeverTraversesAWallCell()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var prevThreshold = StepCache.Instance.MissPromotionThreshold;
StepCache.Instance.MissPromotionThreshold = 1;
@ -74,9 +75,10 @@ public class HousePathRoutingTests
}
}
[Fact]
[SkippableFact]
public void Demolish_ReopensCoveredCells()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);

View file

@ -25,9 +25,10 @@ public class MultiEdgeCaseTests
/// <c>GetStaticAndMultiTiles</c> yields tiles from BOTH multis; the synthesizer must still
/// agree with CheckMovement everywhere over the union footprint + halo.
/// </summary>
[Fact]
[SkippableFact]
public void OverlappingMultis_SynthesizerMatchesCheckMovement()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
@ -75,9 +76,10 @@ public class MultiEdgeCaseTests
/// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the
/// synthesizer agrees with the oracle at every direction either way).
/// </remarks>
[Fact]
[SkippableFact]
public void BoatOverWater_SynthesizerMatchesCheckMovement()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
@ -98,9 +100,10 @@ public class MultiEdgeCaseTests
/// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads
/// the LIVE, post-redesign <c>Components</c> — i.e. it matches CheckMovement on the NEW shape.
/// </summary>
[Fact]
[SkippableFact]
public void RedesignedFoundation_SynthesizerMatchesNewFootprint()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
@ -136,10 +139,11 @@ public class MultiEdgeCaseTests
/// dungeon cave-wall corner; add InlineData rows here — the assertion already covers them by
/// construction. (Left intentionally unhunted: do not invent tree/dungeon coords.)
/// </summary>
[Theory]
[SkippableTheory]
[InlineData(MapId, HouseX, HouseY)] // known-good open Trammel placement (passes today)
public void UserSuppliedScenarios_SynthesizerMatchesCheckMovement(int mapId, int x, int y)
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[mapId];

View file

@ -12,9 +12,10 @@ public class MultiMaskCacheTests
private const int PlaceX = 1480;
private const int PlaceY = 1620;
[Fact]
[SkippableFact]
public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
@ -38,9 +39,10 @@ public class MultiMaskCacheTests
}
}
[Fact]
[SkippableFact]
public void IsInteriorLocalCell_TrueDeepInside_FalseAtEdge()
{
TileDataRequirement.SkipIfMissing();
var multi = new TestMulti(GuildHouseId);
try
{
@ -122,9 +124,10 @@ public class MultiMaskCacheTests
Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50)));
}
[Fact]
[SkippableFact]
public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits()
{
TileDataRequirement.SkipIfMissing();
// (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it
// would never serve the interior cache under the footprint-clean gate. Use a known flat/clear
// spot so a house there is footprint-clean and its interior cells serve from the cache.
@ -242,9 +245,10 @@ public class MultiMaskCacheTests
}
}
[Fact]
[SkippableFact]
public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk()
{
TileDataRequirement.SkipIfMissing();
// (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild
// house there is never footprint-clean. Use a known flat/clear spot for the clean assertion.
const int CleanX = 1560;

View file

@ -11,9 +11,10 @@ public class MultiMaskSynthesisTests
private const int PlaceX = 1480;
private const int PlaceY = 1620;
[Fact]
[SkippableFact]
public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);

View file

@ -17,9 +17,10 @@ public class MultiSplitRoutingTests
private const int PlaceX = 1500;
private const int PlaceY = 1600;
[Fact]
[SkippableFact]
public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
Assert.NotNull(map);

View file

@ -23,9 +23,10 @@ public class MultiWalkabilityTests
public WalkerStub() => Body = 0xC9;
}
[Fact]
[SkippableFact]
public void WallCell_CannotBeEnteredFromAnyAdjacentCell()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
@ -86,9 +87,10 @@ public class MultiWalkabilityTests
}
}
[Fact]
[SkippableFact]
public void FloorCell_IsStandable()
{
TileDataRequirement.SkipIfMissing();
StepCache.Instance.Clear();
var map = Map.Maps[MapId];
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);

View file

@ -347,9 +347,10 @@ public class StepCacheFileTests
/// to <see cref="LazyReader_DoesNotMaterializeUntilQueried"/> which proves the
/// default lazy behavior.
/// </summary>
[Fact]
[SkippableFact]
public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1;
@ -401,9 +402,10 @@ public class StepCacheFileTests
}
}
[Fact]
[SkippableFact]
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build for save phase

View file

@ -54,9 +54,10 @@ public class StepCacheLifecycleTests
Assert.Equal(1L, stats.FallthroughNotBuilt);
}
[Fact]
[SkippableFact]
public void TryGetMask_SecondTouchWithinWindow_PromotesAndBuilds()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
@ -112,9 +113,10 @@ public class StepCacheLifecycleTests
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
}
[Fact]
[SkippableFact]
public void TryGetMask_MultipleCallsInSameFindGeneration_StayInFallthrough()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 2;
@ -207,9 +209,10 @@ public class StepCacheLifecycleTests
Assert.Equal((byte)0, lookup.WalkMask);
}
[Fact]
[SkippableFact]
public void MultiCoveredCell_AndHalo_RouteToFallthrough()
{
TileDataRequirement.SkipIfMissing();
var cache = StepCache.Instance;
cache.Clear();
cache.MissPromotionThreshold = 1; // eager build so a multi-free cell serves immediately

View file

@ -111,9 +111,10 @@ public class StepCacheParityTests
/// (Atlantic coast) and asserts at least one cell has a non-zero WetMask. Catches the
/// failure mode where StepProbe silently bakes zero swim output everywhere.
/// </summary>
[Fact]
[SkippableFact]
public void SwimBake_ProducesWetCells_OnKnownWaterRegion()
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
Assert.NotNull(map);

View file

@ -14,11 +14,12 @@ public class StaticWalkabilityParityTests
_output = output;
}
[Theory]
[SkippableTheory]
[InlineData("britain_inn_dense", 1480, 1610, 32)]
[InlineData("trammel_open_plain", 1500, 1600, 32)]
public void BakerMatchesCheckMovement(string label, int xStart, int yStart, int size)
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
Assert.NotNull(map);

View file

@ -124,9 +124,10 @@ public class StepProbeTests
Assert.True(found, "Expected at least one fully-flat open cell in (1500..1532, 1600..1632)");
}
[Fact]
[SkippableFact]
public void ComputeMaskAt_BritainInnDense_HasCellWithBlockedDirections()
{
TileDataRequirement.SkipIfMissing();
// Invariant: inside the dense Britain inn region, at least one cell must have
// at least one direction blocked by a static. Protects against a regression
// where the baker reports everything as walkable (the original false-pass bug).
@ -181,9 +182,10 @@ public class StepProbeTests
}
}
[Fact]
[SkippableFact]
public void ComputeMaskAt_PinnedCell_TrammelOpenPlainOrigin()
{
TileDataRequirement.SkipIfMissing();
// PINNING test: locks specific output for Trammel (1500, 1600).
// Cell at z=10 with mask 0xC1 (N + W + NW only walkable). The other five
// directions are blocked by water (E/SE/S/SW are wet tiles, NE is shore).
@ -214,9 +216,10 @@ public class StepProbeTests
Assert.Equal((sbyte)10, result.GetWalkZ(Direction.Up));
}
[Fact]
[SkippableFact]
public void ComputeMaskAt_PinnedCell_BritainInnDenseOrigin()
{
TileDataRequirement.SkipIfMissing();
// PINNING test: locks specific output for Trammel (1480, 1610).
// Cell at z=20 with mask 0x3F (N/NE/E/SE/S/SW walkable, W/NW blocked by
// a wall to the west). All walkable directions stay flat at z=20.

View file

@ -176,9 +176,10 @@ public class ApproachTargetTests
Assert.True(caught, "chaser must catch a target that walks away then stops");
}
[Fact]
[SkippableFact]
public void UnreachableTarget_GivesUp_AndIdles()
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1601, out _, out var z, out _);
@ -243,9 +244,10 @@ public class ApproachTargetTests
Assert.True(stayedIdle, "after giving up, the creature must idle, not shuffle");
}
[Fact]
[SkippableFact]
public void WallBetween_RoutesAround_ReachesTarget()
{
TileDataRequirement.SkipIfMissing();
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);