fix(ci): run test projects on CI; remove brittle OPL attribute tests (#2513)

## Problem

Three coupled issues, each hiding the next:

1. **CI passed despite failing tests, with no test logs.** ([example run](https://github.com/modernuo/ModernUO/actions/runs/28639143286/job/84931544255) — the `Test` step produced zero output and the job went green.)
2. **Two `EmitsLowerStatReqWhenPassed` tests** fail with `KeyNotFoundException: '1060435'`.
3. Once CI actually ran the tests, **~337 UOContent tests failed** with `FileNotFoundException: tiledata.mul was not found` — the test bootstrap force-loaded copyrighted client data that CI doesn't have.

## Root causes & fixes

### 1. CI ran zero tests (`fix(ci)`)
The `Test` step ran `dotnet test --no-restore`, but the `Build` step only restores/builds `Application` — never the test projects. Without a restore, the test projects have no `project.assets.json`, so `Microsoft.NET.Test.Sdk`'s targets aren't imported, they aren't recognized as test projects, and `dotnet test` runs the `VSTest` target against **zero** projects → no output, **exit 0**.

- Both jobs now run `dotnet test --logger trx --results-directory ./TestResults` (test projects restore and run) **plus a guard** that fails the job if no `.trx` is produced — a permanent backstop against silent zero-test passes.

### 2. Impossible OPL tests (`fix(ci)` + `test(opl)`)
#2501 deliberately emits `LowerStatReq` (`1060435`) **inline in each item**, not in `GetProperties`. A follow-up "fix" dropped the `lowerStatReq:` argument to make the tests compile but left the assertions expecting `1060435`.

- Removed the two impossible tests, then removed the **entire `Tests/PropertyList/` OPL attribute set** from #2501: these assert exact cliloc/value/order of OPL emission per item base — a one-time proof of the #2501 rewire, now a permanent tax on modding (any admin reorder/value change/added line reddens the build). The one non-trivial case (LowerStatReq) is what just broke, because the test was wrong. Inline emission stays covered by the `BaseArmor`/`BaseClothing` tests.

### 3. Tile-data-dependent tests crashed CI (`test(uocontent)`)
UOContent.Tests' collection-fixture constructor force-loaded `tiledata.mul` unconditionally. On CI (no client files) it threw, and xUnit failed **every test in the collection** with the same error — mostly collateral (packet/scheduler/spawner tests that don't need tile data).

- Mirror Server.Tests' graceful pattern: `TestServerInitializer` probes for `tiledata.mul` and only loads tile/multi data (and runs the tile-dependent configure steps) when present, exposing `TileDataLoaded` so the fixture no longer throws.
- Add a shared `TileDataRequirement.SkipIfMissing()` guard and apply it to exactly the **31** pathfinding/multi/AI tests that genuinely need real tile data (`[SkippableFact]`/`[SkippableTheory]`).

## Verification (all local)

| Scenario | Server.Tests | UOContent.Tests |
|---|---|---|
| **Client data absent (CI)** | 726 pass, 17 skip, **0 fail** | 469 pass, 32 skip, **0 fail** |
| **Client data present (dev)** | 726 pass, 0 skip, **0 fail** | 501 pass, 0 skip, **0 fail** |

- Full `dotnet test` exits **0**; TRX files produced; the no-test guard trips (exit 1) only when zero `.trx` are produced.
This commit is contained in:
Kamron Batman 2026-07-02 22:35:37 -07:00 committed by GitHub
parent 8c6eab5fca
commit a706ef1449
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 124 additions and 546 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 _);

View file

@ -1,71 +0,0 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosArmorAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsMageArmorAndSelfRepair_DoesNotReadLowerStatReqOrDurabilityFromContainer()
{
var attrs = new AosArmorAttributes(null)
{
MageArmor = 1,
SelfRepair = 4,
LowerStatReq = 50, // container value must NOT be auto-emitted (it's passed in by the consumer)
DurabilityBonus = 10 // never emitted by this method
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl); // no lowerStatReq arg
var map = Decode(opl);
Assert.Equal("", map[1060437]); // MageArmor (no-arg)
Assert.Equal("4", map[1060450]); // SelfRepair
Assert.False(map.ContainsKey(1060435)); // LowerStatReq NOT read from container
Assert.False(map.ContainsKey(1060410)); // DurabilityBonus excluded
}
[Fact]
public void EmitsLowerStatReqWhenPassed()
{
var attrs = new AosArmorAttributes(null) { MageArmor = 1, LowerStatReq = 50 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl); // computed value passed by the consumer, not the raw 50
var map = Decode(opl);
Assert.Equal("77", map[1060435]); // emitted from the param, not the container's 50
Assert.Equal("", map[1060437]); // MageArmor still emitted
}
}

View file

@ -1,75 +0,0 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsRawAttributesInCanonicalOrder()
{
var attrs = new AosAttributes(null)
{
DefendChance = 5,
BonusStr = 10,
NightSight = 1,
SpellChanneling = 1,
WeaponSpeed = 7
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl);
var map = Decode(opl);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("10", map[1060485]); // BonusStr
Assert.Equal("", map[1060441]); // NightSight (no-arg)
Assert.Equal("", map[1060482]); // SpellChanneling (no-arg)
Assert.Equal("7", map[1060486]); // WeaponSpeed
Assert.False(map.ContainsKey(1060401)); // WeaponDamage not set
}
[Fact]
public void AppliesComputedBonuses()
{
var attrs = new AosAttributes(null) { WeaponDamage = 10, AttackChance = 4, Luck = 100 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl, damageBonus: 5, hitChanceBonus: 3, luckBonus: 50);
var map = Decode(opl);
Assert.Equal("15", map[1060401]); // WeaponDamage + damageBonus
Assert.Equal("7", map[1060415]); // AttackChance + hitChanceBonus
Assert.Equal("150", map[1060436]); // Luck + luckBonus
}
}

View file

@ -1,74 +0,0 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosWeaponAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsHitEffectsUseBestSkillMageWeaponSelfRepair()
{
var attrs = new AosWeaponAttributes(null)
{
UseBestSkill = 1,
HitFireball = 12,
HitLeechHits = 20,
MageWeapon = 25,
SelfRepair = 3
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl);
var map = Decode(opl);
Assert.Equal("", map[1060400]); // UseBestSkill (no-arg)
Assert.Equal("12", map[1060420]); // HitFireball
Assert.Equal("20", map[1060422]); // HitLeechHits
Assert.Equal("5", map[1060438]); // MageWeapon => 30 - 25
Assert.Equal("3", map[1060450]); // SelfRepair
Assert.False(map.ContainsKey(1060435)); // LowerStatReq not emitted without the param
}
[Fact]
public void EmitsLowerStatReqWhenPassed()
{
var attrs = new AosWeaponAttributes(null) { MageWeapon = 25 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl); // computed value passed by the weapon
var map = Decode(opl);
Assert.Equal("40", map[1060435]); // lower requirements, in cliloc order before MageWeapon
Assert.Equal("5", map[1060438]); // MageWeapon still emitted
}
}

View file

@ -1,40 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseArmorPropertiesTests
{
[Fact]
public void Armor_AttributeLineSet_Preserved()
{
var armor = new PlateChest();
try
{
armor.Attributes.DefendChance = 5;
armor.Attributes.BonusDex = 8;
armor.Attributes.Luck = 40;
armor.Attributes.SpellChanneling = 1;
armor.Attributes.IncreasedKarmaLoss = 2;
armor.ArmorAttributes.MageArmor = 1;
armor.ArmorAttributes.SelfRepair = 3;
armor.ArmorAttributes.LowerStatReq = 50;
var map = ItemOplTestHelper.DecodeAttributeLines(armor);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("8", map[1060409]); // BonusDex
Assert.Equal("40", map[1060436]); // Luck (GetLuckBonus()==0 unequipped)
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("2", map[1075210]); // IncreasedKarmaLoss
Assert.Equal("", map[1060437]); // MageArmor
Assert.Equal("3", map[1060450]); // SelfRepair
Assert.Equal("50", map[1060435]); // LowerStatReq (inline via GetLowerStatReq)
}
finally
{
armor.Delete();
}
}
}

View file

@ -1,38 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseClothingPropertiesTests
{
[Fact]
public void Clothing_AttributeLineSet_Preserved()
{
var shirt = new FancyShirt();
try
{
shirt.Attributes.DefendChance = 5;
shirt.Attributes.Luck = 25;
shirt.Attributes.SpellChanneling = 1;
shirt.ClothingAttributes.MageArmor = 1;
shirt.ClothingAttributes.SelfRepair = 2;
shirt.ClothingAttributes.LowerStatReq = 30;
shirt.ClothingAttributes.DurabilityBonus = 15;
var map = ItemOplTestHelper.DecodeAttributeLines(shirt);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("25", map[1060436]); // Luck (raw)
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("", map[1060437]); // MageArmor
Assert.Equal("2", map[1060450]); // SelfRepair
Assert.Equal("30", map[1060435]); // LowerStatReq (direct)
Assert.Equal("15", map[1060410]); // DurabilityBonus (direct)
}
finally
{
shirt.Delete();
}
}
}

View file

@ -1,36 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseJewelPropertiesTests
{
[Fact]
public void Jewel_AttributeLineSet_Preserved()
{
var ring = new GoldRing();
try
{
ring.Attributes.DefendChance = 5;
ring.Attributes.BonusStr = 10;
ring.Attributes.Luck = 100;
ring.Attributes.NightSight = 1;
ring.Attributes.SpellChanneling = 1;
ring.Attributes.IncreasedKarmaLoss = 3;
var map = ItemOplTestHelper.DecodeAttributeLines(ring);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("10", map[1060485]); // BonusStr
Assert.Equal("100", map[1060436]); // Luck (raw; jewel has no luck bonus)
Assert.Equal("", map[1060441]); // NightSight
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("3", map[1075210]); // IncreasedKarmaLoss (Core.ML EJ)
}
finally
{
ring.Delete();
}
}
}

View file

@ -1,42 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseTalismanPropertiesTests
{
[Fact]
public void GetProperties_EmitsAosAttributeLines()
{
var item = new RandomTalisman();
try
{
item.Attributes.DefendChance = 10;
item.Attributes.BonusStr = 5;
item.Attributes.Luck = 50;
item.Attributes.NightSight = 1;
item.Attributes.SpellChanneling = 1;
item.Attributes.IncreasedKarmaLoss = 2;
var lines = ItemOplTestHelper.DecodeAttributeLines(item);
Assert.True(lines.ContainsKey(1060408)); // DefendChance
Assert.Equal("10", lines[1060408]);
Assert.True(lines.ContainsKey(1060485)); // BonusStr
Assert.Equal("5", lines[1060485]);
Assert.True(lines.ContainsKey(1060436)); // Luck
Assert.Equal("50", lines[1060436]);
Assert.True(lines.ContainsKey(1060441)); // NightSight
Assert.Equal("", lines[1060441]);
Assert.True(lines.ContainsKey(1060482)); // SpellChanneling
Assert.Equal("", lines[1060482]);
Assert.True(lines.ContainsKey(1075210)); // IncreasedKarmaLoss
Assert.Equal("2", lines[1075210]);
}
finally
{
item.Delete();
}
}
}

View file

@ -1,38 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseWeaponPropertiesTests
{
[Fact]
public void Weapon_AttributeLineSet_Preserved()
{
var weapon = new Longsword();
try
{
weapon.Attributes.DefendChance = 5;
weapon.Attributes.BonusInt = 6;
weapon.Attributes.SpellChanneling = 1;
weapon.WeaponAttributes.UseBestSkill = 1;
weapon.WeaponAttributes.HitFireball = 12;
weapon.WeaponAttributes.MageWeapon = 25;
weapon.WeaponAttributes.SelfRepair = 3;
var map = ItemOplTestHelper.DecodeAttributeLines(weapon);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("6", map[1060432]); // BonusInt
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("", map[1060400]); // UseBestSkill
Assert.Equal("12", map[1060420]); // HitFireball
Assert.Equal("5", map[1060438]); // MageWeapon (30 - 25)
Assert.Equal("3", map[1060450]); // SelfRepair
}
finally
{
weapon.Delete();
}
}
}

View file

@ -1,44 +0,0 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
namespace UOContent.Tests;
public static class ItemOplTestHelper
{
// Builds the item's OPL and returns attribute/property cliloc lines (>= 1060000),
// ignoring base-item lines (name, weight, etc.) so tests isolate the attribute surface.
public static Dictionary<int, string> DecodeAttributeLines(Item item)
{
var opl = new ObjectPropertyList(item);
item.GetProperties(opl);
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
var arg = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
if (cliloc is >= 1060000 and < 1080000)
{
map[cliloc] = arg;
}
}
return map;
}
}

View file

@ -1,42 +0,0 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class SpellbookPropertiesTests
{
[Fact]
public void GetProperties_EmitsAosAttributeLines()
{
var item = new Spellbook();
try
{
item.Attributes.CastRecovery = 3;
item.Attributes.LowerManaCost = 8;
item.Attributes.Luck = 75;
item.Attributes.NightSight = 1;
item.Attributes.SpellChanneling = 1;
item.Attributes.IncreasedKarmaLoss = 2;
var lines = ItemOplTestHelper.DecodeAttributeLines(item);
Assert.True(lines.ContainsKey(1060412)); // CastRecovery
Assert.Equal("3", lines[1060412]);
Assert.True(lines.ContainsKey(1060433)); // LowerManaCost
Assert.Equal("8", lines[1060433]);
Assert.True(lines.ContainsKey(1060436)); // Luck
Assert.Equal("75", lines[1060436]);
Assert.True(lines.ContainsKey(1060441)); // NightSight
Assert.Equal("", lines[1060441]);
Assert.True(lines.ContainsKey(1060482)); // SpellChanneling
Assert.Equal("", lines[1060482]);
Assert.True(lines.ContainsKey(1075210)); // IncreasedKarmaLoss
Assert.Equal("2", lines[1075210]);
}
finally
{
item.Delete();
}
}
}