Table of Contents

Class DevTest

Namespace
GrindFest
Assembly
GrindFest.dll

Base class for in-game developer tests. Tests run as coroutines in the live game — no NUnit, no scene reload. Editor-only (uses Addressables, Cheats patterns).

Each public method returning IEnumerator is discovered as a test. Group related tests in one class, each method is a separate test case.

public abstract class DevTest
Inheritance
object
DevTest
Derived

Properties

BotHero

Current hero's AutomaticHero (bot API — same methods players use in bot scripts).

ExpectedLogErrors

Substrings of the error-level log messages this test provokes on purpose. Anything a test logs at error level that is not declared here fails it.

Failed
FailureReason
Hero

Current hero's CharacterBehaviour.

HeroPosition

Current hero's position — convenience for spawn placement.

LastActionResult

The result TryDoAction(Func<ActionResult>, float) last saw. Only meaningful immediately after that call.

Name
NeedsFreshUi

True for a test that reads the OneJS UI, which makes the runner reload the whole UI before it. That reload is the only reset that reaches the state a panel keeps while it stays mounted but hidden - the Character screen's selected tab, for instance, which made two CharacterScreenUiTests fail with "Timeout waiting for 'Strength' visible" after the tab had been picked by hand before the run.

Opt-in because it is not free: measured 2026-09-16, reloading before every test left 888 s of a 1194 s suite outside the test bodies (against 119 s in the 358 s run without it), roughly 8,5 s per test. Only the tests that actually read the UI pay that.

Passed
Skipped

The test could not run in this build at all (see DevTestSkipException). Deliberately neither passed nor failed: it is not evidence either way.

UiRoot

The live OneJS UI root element.

Methods

AcquireMonster(params string[])

Makes sure a monster prefab is resident before something reads it synchronously.

SpawnMonster(string, int, MonsterRank, float) resolves through MonsterPool.TryGetMonsterPrefab, which by design loads nothing — so a monster no test had touched yet made the spawn assert fail claiming the monster was "not found in content", when the definition was in the index the whole time (measured 2026-09-19: CombatTests.KillZombie, MaterializerHydrationTests and ContentHotReloadTests all failed on 'Zombie' / 'Zombie Shepherd' for exactly that reason). Requesting through RequestMonsterPrefab and waiting is the path a real spawner takes.

Assert(bool, string)
AssertEqual<T>(T, T, string)
AssertGreaterThan(float, float, string)
AssertNotNull(object, string)
AssertNull(object, string)
AssertPanelClosed(string)

Setup helper: the panel is not the thing the player is looking at.

AssertPanelOnScreen(string)

The panel is open AND somewhere the player can see it.

WaitForPanel/IsPanelVisible only ask whether the element is displayed, which is not the same question: a DraggablePanel that failed to position itself sits at x=9999 with a perfectly real rect and passes every one of those checks. Six panels were in that state in the v3 migration and the existing tests all passed, so this is the assertion to use for "did it open".

AssertTextNotVisible(string)
AssertTextVisible(string)
Cleanup()

Destroy all objects spawned by this test. Called automatically by DevTestRunner, but tests can also call it manually.

ClickById(string)

Click by JSX id (OneJS maps id -> VisualElement.name). Use when text is ambiguous.

ClickText(string)

Click the visible element with this text - the way a player clicks what they read.

ClickTextWithin(string, string)

Click a control inside a labelled row, e.g. the "+" next to "Strength".

CloseAllPanels()

Setup helper: close every JS panel so a test starts from a known state.

DescribeGroundBelow(Vector3, Transform)

Describes what IS below a position when it is not terrain - so a gate that refuses to pass names the object that fooled it, instead of leaving "no ground" to be guessed at.

DescribePanelRect(string)

The panel's rect and the screen it sits in - the two numbers that say "parked off-screen".

DoAction(string, Func<ActionResult>, float)

Poll a bot API action (e.g. Equip, DropInto, InteractWith) until it returns Done or fails/times out.

EnsureContentLoaded()

Makes sure the content database is loaded before a test reads it, and returns false when this platform cannot do that synchronously.

ContentDB.Load() is editor/test-only by design — a player has no synchronous content load, a browser cannot block on one — so calling it in a player throws InvalidOperationException. That is what produced the two InvalidOperationException failures in the 2026-09-20 WebGL run. Tests that need the content call this instead and leave with a clear line:

if (!EnsureContentLoaded()) { yield break; }
EvalUi(string)

Run JS in the live UI context. How a test asks the app for something a player cannot click (opening a panel directly, or reading a value) without duplicating the runner lookup.

GetLiquidAmount(LiquidContainerBehaviour, ResourceType)

Get total amount of a specific liquid type in a container.

GetTotalLiquid(LiquidContainerBehaviour)

Get total liquid amount (all types) in a container.

GiveItemToHero(ItemBehaviour)

Put an item into the hero's inventory. Heavy items (IsHeavy) are equipped directly instead of going into inventory.

GiveSkill<T>()

Give the hero a skill — converted (plain C#) or legacy MonoBehaviour, whatever the type is.

IsNavMeshReadyAt(Vector3)

Does a NavMesh exist within 10 m of worldPos?

IsPanelOpen(string)

Is this panel (a PanelIds entry) on screen right now?

IsTerrainReadyAt(Vector3)

Is the world ready to have something put at worldPos?

Two things have to be true, and checking only the first is the bug this exists to prevent:

  1. MapMagic generated the tile containing that position and published its layers. The tile object existing is not enough - the data has to be applied.
  2. There is a collider underneath. Not "the heightmap exists" but "something can be landed on": DropAround(Vector3, float) throws items 2.5 m into the air with an upward impulse and lets gravity finish the job, so an item spawned into a tile whose collider is not live yet falls through the world and every later assertion about it reads 0 / null / "should be destroyed".
IsTextVisible(string)

Is this text actually on screen right now (hidden panels stay mounted).

LoadAsset<T>(string, Action<T>, AssetTier)

Loads an address through the content platform from a coroutine test.

Log(string)
OpenMainMenu(float, int)

Open the main menu the way a player does - by pressing ESC.

The OneJS app registers handleEscape only after its first render, and a test can start before that on a fresh play session (game init is not the same thing as the JS app being up - e.g. an exception during GameManager.OnGameInitialized leaves the UI booting late). So this retries with a short wait instead of failing on that race.

This cannot mask the ESC bug these tests guard: a broken ESC stays broken no matter how often it is pressed, so the retry only ever helps a not-yet-ready app.

PlaceItemOnGround(ItemBehaviour, Vector3, string)

Put a spawned item down on ground that has actually been checked, with its physics at rest. Use this instead of writing item.transform.position = ... directly.

PlaceItemOnGroundWhenReady(ItemBehaviour, Vector3, string, float)

PlaceItemOnGround(ItemBehaviour, Vector3, string) that first waits for terrain AT THE TARGET, then checks the item is still there a moment later.

PressEscape()

ESC as the game receives it. PlayerInput reads ESC from the legacy Input Manager, which UI events never feed, so this enters at exactly the point PlayerInput does once its key check passes.

PressEscapeAndWait(float)

ESC the way a player presses it: dispatch the key, then wait a moment before the caller presses again. The gap is part of simulating a player, not padding.

OneJS re-registers its ESC handler in a deferred effect, so the new handler only exists after the frame that applied the previous state change. A second ESC sent in that same frame still reaches the OLD handler, which repeats what it just did - the press looks ignored even though the app is fine. A player cannot press twice inside one frame (~16 ms); a test can, so it has to wait like one.

This cannot mask the bug these tests guard: a handler that is never re-armed because the app is broken stays broken however long the gap is.

ProbeMainMenu(string[])

Opens the main menu and reports the verdict, without failing anything.

RequireRuntimeCompilation()

Precondition for a test that compiles a script at runtime. Where the platform cannot load dynamic code (WebGL: ahead-of-time, no JIT) this throws DevTestSkipException and the runner records the test as skipped.

SpawnItem(string, int, ItemRank, int)

Spawn an item by content address. Drops it on the ground near the hero.

SpawnMonster(string, int, MonsterRank, float)

Spawn a monster by name (must be resident — AcquireMonster(params string[]) first if the run may not have touched it). Placement is NavMesh-sampled near the hero. Yield a frame after calling to let it initialize.

SpawnProp(string)

Spawn a prop (non-item like Cauldron) by content address. Placed near hero.

TrackForCleanup(GameObject)

Track a manually-spawned GameObject for automatic cleanup at end of test.

TryDoAction(Func<ActionResult>, float)

Poll a bot API action like DoAction(string, Func<ActionResult>, float), but return instead of throwing.

TryGetClearGround(Vector3, out Vector3, out string, Transform)

Ground for something a test is about to place, chosen so that it does not end up inside the scenery. Tries the requested spot first, then a ring around it, and reports what was in the way.

TryGetGroundPoint(Vector3, out Vector3, Transform)

The ground point under a world position, or false when there is nothing to stand on yet.

"Ground" is GroundLayerMask - Floor, Terrain, Water - i.e. the definition the game itself uses, not "any collider". The old ~0 version is what buried a planter pot 2.5 m UNDER the terrain on 2026-09-20 (hero at -191.81, 11.33, 568.79): the probe returned three hits in unsorted order - terrain 11.37, a backdrop 'Plane' on the Darkness layer at 8.77, the hero's trigger at 18.80 - took whichever came first, and placed the pot on the Darkness plane. Inside the world, invisible, and every later assertion about it read 'Empty' instead of naming a fall.

The hero's own colliders are always skipped: the ray starts above them and would hit the capsule first, which would report "ground" for a hero falling through an empty world. Pass ignore additionally to ask "is there ground here apart from this object?" - the only way to measure under a prop, since the prop's own collider is otherwise the first hit.
TryGetTerrainPoint(Vector3, out Vector3, Transform)

Like TryGetGroundPoint(Vector3, out Vector3, Transform) but only accepts a TerrainCollider.

TryWaitUntil(Func<bool>, float)

Like WaitUntil, but returns false instead of throwing on timeout. Use it for setup that may legitimately need a retry (compare WaitUntil, which is an assertion about behaviour).

VisibleTextsDescription()

Every text a player can currently read - for assertion messages.

WaitForNavMeshAt(Vector3, float, bool)

Waits for a NavMesh near worldPos, reporting what it measured while it waits.

WaitForPanel(string, bool, float)

Wait for a panel to be on screen (open: true) or gone (open: false). This is the state probe - use it for "did the menu open?". Keep the text helpers (WaitForText) for the other half of the question: "did this panel's content render?", which a panel-id check cannot see (a panel can be up while its tabs render nothing).

WaitForPanelOnScreen(string, float)

Same question as AssertPanelOnScreen(string), asked as a WAIT.

Placement is one bridge tick behind "the panel is displayed": the JS state that holds left/top updates a frame after display flips, and UI Toolkit then has to lay the element out. A test that asserts in the same tick is measuring the harness's frame timing, not the product - measured 2026-09-20, both UiClickThroughTests did exactly that and read the 9999 fallback. It still fails, with a timeout and the panel's rect, when the panel never arrives.

WaitForStableGroundAt(Vector3, int, float)

Wait until the terrain at this position has STOPPED MOVING - not merely until it exists.

WaitForTerrain(float, bool)

Wait for the terrain under the current hero. Needed after moving the hero somewhere new; not needed at the start of a test, because CleanSlate waits for the hero's tile before handing over.

WaitForTerrainAt(Vector3, float, bool)

Wait until IsTerrainReadyAt(Vector3) is true, or give up after timeout real (unscaled) seconds.

WaitForText(string, float)
WaitForTextGone(string, float)
WaitUntil(string, Func<bool>, float, Func<string>)

Wait until a condition is true, or throw on timeout.