Loot — Testing Philosophy¶
Where the tests live¶
Tests/EditMode/Loot/InternalTruth/
LootRollerTests.cs 18 cases — the odds
LootServiceDeliveryTests.cs 18 cases — where awards end up
LootServiceModifierOrderAndReentrancyTests.cs 12 cases — modifier order, and rolling during a roll
Tests/PlayMode/Integrations/LootPickups/
LootPickupDelivery_PlayMode_Tests.cs 11 cases — the spawner and the payload, in a scene
Two assemblies. RevFramework.Loot.Tests.EditMode.InternalTruth is constrained on REV_LOOT_PRESENT and references only RevFramework.Loot and RevFramework.Core.Abstractions. RevFramework.Integrations.Loot.Pickups.Tests.PlayMode covers the Pickups integration and is constrained on REV_LOOT_PRESENT and REV_PICKUPS_PRESENT, so it compiles only where the integration it tests does.
Why the core is EditMode, and why that is not a shortcut¶
Most systems here need Play Mode because their behaviour lives in Awake, OnEnable and component lifecycles. Loot's core does not, and that is the design working rather than the tests cutting a corner. What genuinely needs a scene — spawning carriers, colliding with them — has its own Play Mode suite rather than a fake.
Rolling is a pure function. LootRoller.Roll(table, rng) takes data and returns data, so asserting that a weighted table converges on its authored weights over 20,000 samples is arithmetic — no scene, no frames, no Play Mode.
Delivery is tested through the adapter seams. LootService.__BindAdapters is an internal test-only entry point that injects fake adapters directly, so "the container refused this award" is a one-line fake rather than a real Inventory stack standing up in a scene. That is what makes the delivery cases cheap enough to be exhaustive about failure.
The Hostile / InternalTruth split¶
Like the other systems, Loot splits its tests into Hostile (public surface only) and InternalTruth (internals). The line falls in a specific place, and it is worth knowing where.
| Suite | Holds | Why there |
|---|---|---|
Hostile | The 15 roller tests | Tables come from the public LootTable.Create, the random source is a local IRandomProvider, nothing is reflected. Every assertion is one you could write in your own project. |
InternalTruth | The 3 cycle tests | A cycle is not constructible through the public factory, so proving the guard needs the private field. |
InternalTruth | The service and delivery tests | They inject adapters through LootService.__BindAdapters, which is internal. |
What makes the Hostile half possible is that you can build a table. LootTable.Create is public, so a drop-rate assertion needs nothing from inside the framework — and the fakes the delivery tests inject are built on ILootInventoryAdapter, ILootCurrencyAdapter and ILootPickupSpawner, all three public too. Between them, most of what this suite does is reproducible in your own project against your own tables.
Why the cycle tests could not follow¶
A LootEntry is a struct holding a table reference, so the table being pointed at has to exist before the entry pointing at it can be made. Closing the loop means editing a table that is already built, and nothing public edits a built table.
That is a property of the API rather than a hole in it — a customer cannot author a cycle in code even by accident. The case the guard exists for is the one a designer can still reach: two table assets dragged into each other in the Inspector.
The control, ATableReachedTwiceDownDifferentBranchesIsNotACycle, stayed with them rather than moving to the larger suite. A negative control in a different assembly from the positives it controls is one refactor away from looking like an orphan, and its value is in being read beside them.
What the roller tests prove¶
- The odds are the authored odds — a weighted table converges on its weights over a large sample, and a zero-weight entry is never picked.
- Both
Value01bounds are handled.Weighted_RandomAtUpperBoundStillPicksAnEntrypins the case where the RNG returns exactly1.0, which lands precisely on the end of the last weight bucket;Independent_ChanceZeroNeverAwardsAndChanceOneAlwaysDoespins the mirror problem, where a naive comparison awards at chance 0 or refuses at chance 1. This is the single most valuable pair in the suite — the framework's other seeded provider returns[0,1), so this class of bug has no analogue elsewhere and would not have been found by comparison with Crafting. - Degenerate tables behave — all-weights-zero produces nothing, inverted roll bounds roll the minimum, a null table or null RNG produces an empty result rather than throwing.
- Quantity honours its bounds, and an unset quantity awards one rather than nothing.
- Nesting works and terminates — a nested table folds its awards into the parent, and a table reaching itself is caught by a path check. Note what the guard actually buys: without it a cycle does not hang, it awards once per level until
MaxNestingDepthstops it. The guard makes that bounded-but-wrong result an empty one, and the suite pins the discrimination in both directions — a two-table cycle is caught, and the same table reached down two different branches is not treated as one. - A seed reproduces a roll, which is what makes the debugger's sampling meaningful.
What the delivery tests prove¶
They exist because of one bug: the service used to discard every delivery result, so a full container destroyed the drop while Granted announced it anyway.
- A refused container reports the award as
Undeliveredand does not raiseGranted. Those are two separate cases on purpose — reporting the loss is worthless if the success event fires anyway. - A partly-fitting roll splits across both events, because delivery is per-award.
- A missing inventory or currency adapter reports every award of that kind as undelivered.
RollAndGrantfalls back to spawning even with spawn mode off, which is the behaviour that turns a full bag from a loss into a pickup at the player's feet.- When the container and the spawner both refuse, the award is reported as undelivered.
Grantnever spawns, even with a spawner bound and delivery failing — the test that stops a collected pickup spawning another for ever.
What the modifier and reentrancy tests prove¶
- Modifiers run lowest priority first, and modifiers tied on priority run in the order their components were added — a tie broken by nothing is a tie broken by whatever the engine hands back.
- A disabled modifier does not run, which is the one thing an inspector toggle has to mean.
- Rolling from inside a roll returns the caller its own awards, and granting from inside a
Grantedsubscriber reports that grant's own delivered and lost awards. Reentrancy is the shape where a system quietly returns someone else's results; there is a non-reentrant control beside it so the assertions cannot pass by accident.
What the Play Mode tests prove¶
The spawner and the payload carrier are the parts a fake cannot stand in for, so they run in a scene:
- A prefab with no payload spawns nothing at all — and nothing accumulates across many awards, which is the difference between refusing and leaking.
- A prefab carrying a payload spawns exactly one carrier per award.
- Scatter respects the configured plane — offsets spread across XZ on the default plane and XY on the screen plane, rather than pushing drops through the floor or into the camera.
- A failed collection leaves the pickup collectable, including with
destroyOnCollectset, and it can still be collected afterwards. A successful one consumes it, and so does a collection with nothing to give — there is nothing to come back for.
What is deliberately not tested¶
Stated plainly, because an untested area you know about is a decision and one you do not is a gap:
- The two teaching panels. They render through
OnGUI, which does not fire under-nographics, so they are covered only by the graphicalPanelRenderSmokeTestsrun — which proves they do not throw, not that they do the right thing. - The debugger window. Nothing renders an editor window under test here. That is a convention about windows, not about editor code — the editor tooling that has to be right is covered, under
Tests/EditMode/Foundation(orphan scanning, the pre-build cleaner, linked-path detection) andTests/EditMode/Packaging(define sync, the SKU exporter, doc drift). - The shipped adapters themselves.
LootInventoryAdapterandLootCurrencyAdapterare thin translations onto Inventory and Currency, whose own suites cover the behaviour underneath. The seam is tested with fakes; the translation is not. LootDropOnDeath.LootPickupSpawnerandLootPickupPayloadare covered in a scene by the Play Mode suite above; the death hook is not, and is now the largest genuine gap in Loot's coverage. It depends on Health raising death at a point a fake cannot reproduce faithfully.- Item and currency ids. Nothing checks a GUID against the
ItemDatabase, in tests or at runtime.
Running them¶
Unity.exe -runTests -batchmode -projectPath <project> -testPlatform EditMode -testFilter "RevGaming.RevFramework.Loot.Tests" -testResults results.xml -logFile run.log
The Play Mode suite sits under a different namespace and needs its own run — the filter above does not reach it:
Unity.exe -runTests -batchmode -projectPath <project> -testPlatform PlayMode -testFilter "RevGaming.RevFramework.Integrations.Loot.Pickups.Tests" -testResults results-play.xml -logFile run-play.log
Read the results XML rather than the exit code, and check the completion line says tests were actually executed — a filter that matches nothing reports success.