Abilities & Spells Test Plan - UQcsse3200/2026-studio-4 GitHub Wiki
Overview
This test plan covers the player's abilities and spells:
- The ability framework โ
PlayerAbility,TimedPlayerAbilityandPlayerAbilitiesComponent, which owns cooldowns, the alive/unlock gating and theabilityUsed/abilityEnded/abilityFailedevents (Player Abilities and Status Effects). - The two abilities: Invisibility (cast) and Last Stand (passive).
- The spell pipeline โ
SpellComponent, its cooldown, and the shared area disc drawn bySpellAoeVisualComponent. - The two spells: Lightning and Freeze.
- The five targeting strategies that decide who a spell catches, and the
EnemyUtilslayer check behind them. - Player wiring (
PlayerFactory) and the two debug-terminal entry points,abilityandspell.
An ability and a spell are deliberately different things. An ability acts on the player and its lifetime is owned by the player's status effects controller; a spell acts on enemies and owns its own cooldown. Both are tested here because both are cast by the player and both apply status effects.
Testing is split into automated unit tests (what the code must do) and manual gameplay tests (what the player must see). Every behaviour in the automated plan is enforced by a test in the repository โ the test class is named above each table.
Test Environment
- Unit testing: JUnit 5 and Mockito, run headlessly with the engine's
GameExtension. The game clock is mocked, so every cooldown and duration is asserted at its exact millisecond rather than by sleeping. - Game testing: Desktop application.
- Movement:
WASDโ Debug terminal:F1 - Terminal commands:
ability invisibility # cast invisibility now, honouring its cooldown
ability laststand # unlock the passive; it still waits for the health threshold
spell lightning # cast lightning, honouring its cooldown
spell freeze # cast freeze, honouring its cooldown
Abilities and spells have no keybinding yet, so the terminal is the only way to cast them in game.
- Tuned values as shipped (
PlayerFactory): both spells use a 3 world-unit radius and a 5 s cooldown; lightning deals 25 damage and flashes purple for 400 ms; freeze lasts 5 s. Invisibility runs 15 s on a 45 s cooldown; Last Stand runs 10 s on a 60 s cooldown and triggers strictly below 20% of max health.
Run the automated tests with:
cd source
./gradlew :core:test
Coverage is written to source/core/build/reports/jacoco/test/html/index.html by:
cd source
./gradlew :core:jacocoTestReport
Coverage
Last verified run: 17 Sept 2026 on main (6f74305) โ 1178 tests, 0 failures project-wide, of which 126 tests across 13 classes cover abilities and spells.
Reference: SonarCloud project overview ยท coverage by component
The badges above are project-wide (72.3% overall, 75.0% line, 66.0% branch). The figures below are the abilities and spells code only, taken from the same analysis.
Per-file coverage
Figures are SonarCloud's, confirmed against a local JaCoCo run of the same revision. Each file links to its SonarCloud measures page.
| File | Lines | Line coverage | Branch coverage | Overall |
|---|---|---|---|---|
PlayerAbilitiesComponent.java |
161 | 100.0% | 88.9% | 95.3% |
PlayerAbility.java |
51 | 94.7% | โ | 94.7% |
TimedPlayerAbility.java |
59 | 92.0% | 66.7% | 81.4% |
abilities/Invisibility.java |
17 | 100.0% | โ | 100.0% |
abilities/LastStand.java |
23 | 100.0% | 100.0% | 100.0% |
spells/SpellComponent.java |
73 | 100.0% | 100.0% | 100.0% |
spells/LightningSpellComponent.java |
35 | 100.0% | 100.0% | 100.0% |
spells/FreezeSpellComponent.java |
23 | 100.0% | 100.0% | 100.0% |
spells/SpellAoeVisualComponent.java |
72 | 100.0% | 100.0% | 100.0% |
spells/targeting/ (6 files) |
144 | 100.0% | 100.0% | 100.0% |
commands/AbilityCommand.java |
30 | 100.0% | 100.0% | 100.0% |
commands/SpellCommand.java |
29 | 100.0% | 100.0% | 100.0% |
Aggregate across all 17 classes (JaCoCo, source/core/build/reports/jacoco/test/html/index.html):
| Counter | Covered | Missed | Coverage |
|---|---|---|---|
| Instructions | 1376 | 11 | 99.2% |
| Branches | 193 | 14 | 93.2% |
| Lines | 334 | 3 | 99.1% |
| Methods | 82 | 2 | 97.6% |
| Classes | 17 | 0 | 100.0% |
The whole spells package, the targeting package and the abilities package are each at 100% line and branch coverage. The three uncovered lines are all in the two abstract base classes and are named under Known coverage gaps.
Tests per class
| Test class | Tests |
|---|---|
PlayerAbilitiesComponentTest |
21 |
SpellComponentTest |
17 |
LightningSpellComponentTest |
11 |
SpellAoeVisualComponentTest |
11 |
FreezeSpellComponentTest |
10 |
StrategyWithinRadiusTest |
10 |
SpellCommandTest |
9 |
StrategyOnscreenTest |
8 |
StrategyClosestTest |
7 |
EnemyUtilsTest |
6 |
AbilityCommandTest |
6 |
StrategyAllTest |
5 |
PlayerFactorySpellsTest |
5 |
| Total | 126 |
Automated Unit Test Plan
Abilities
PlayerAbilitiesComponentTest
The component owns cooldowns and gating for every ability; the status effects controller owns the active lifetime. These tests hold that split in place, and are written against a mocked clock so each deadline is asserted at the exact millisecond.
| Test | Purpose | Expected Result |
|---|---|---|
shouldStartInactiveAndTreatMissingTargetsAsNotConcealed |
Inspect a fresh player, then cast invisibility. | Nothing is active and no events have fired; neither a missing target nor one without effects reads as concealed, and the player does only once invisibility is running. |
shouldExpireInvisibilityAtExactDurationWithoutFrameUpdate |
Cast, then step the clock to 14 999 ms and 15 000 ms. | Active at 14 999, inactive at exactly 15 000 without needing a frame; the 45 s cooldown keeps counting down past the end, and abilityEnded fires once. |
shouldRejectRepeatedCastsWithoutExtendingEitherDeadlineAndRecastAtExactCooldown |
Spam the cast during the run and the cooldown, then recast at exactly 45 000 ms. | Every early cast is refused with abilityFailed(..., "Ability is on cooldown") and extends neither deadline; the recast at the exact cooldown succeeds with a full duration. |
shouldRequireEnablingAndNewDamageEvenWhenAlreadyBelowThreshold |
Drop below 20% health before unlocking Last Stand, then unlock and take 1 more damage. | Unlocking alone never starts the passive โ it waits for a fresh hit. Unlocking twice is idempotent. |
shouldRequireHealthStrictlyBelowTwentyPercent |
Take damage landing exactly on 20% of max health, then below it. | The threshold is strict: exactly 20% does not trigger, below it does. |
shouldUseCurrentMaximumHealthWithoutIntegerRoundingOrOverflow |
Trigger against unusual max-health values. | The threshold is evaluated against current max health in 64-bit arithmetic, so it neither rounds nor overflows. |
shouldNotTriggerFromDirectHealthSettersOrNonhostileDamage |
Set health directly, and take damage from a non-hostile source. | Neither starts the passive โ only a hostile damageTaken event can. |
shouldIgnoreZeroNegativeAndBlockedDamage |
Fire damage events of 0, negative, and fully blocked damage. | Ignored; a hit must actually remove health. |
shouldNotTriggerFromLethalDamageOrNonpositiveRemainingHealthEvents |
Take a killing blow. | The passive does not fire on death โ it is a last stand, not a revive. |
shouldKeepLastStandDeadlinesThroughRepeatedEnablingAndDamage |
Re-unlock and take further hits mid-run. | The run and cooldown keep their original deadlines rather than being refreshed. |
shouldKeepAbilitiesIndependentAndNeverMutateRawStats |
Run both abilities at once and read raw stats. | Each keeps its own cooldown and duration, and neither writes to a raw stat โ the buff lives entirely in the effect. |
shouldClearBothEffectsBeforeReentrantEndListenersAndNotifyOnlyOnce |
End both while a listener re-enters the component. | Both are off before any end listener runs, and abilityEnded fires exactly once per ability. |
shouldEndEffectsImmediatelyOnDeathAndRequireReenablingAfterRevival |
Die mid-run, then revive. | Everything comes off at once, cooldowns clear and unlocks reset, so a new life starts clean. |
shouldDisposeThroughEntityLifecycleAndLeaveRetainedCallbacksInert |
Dispose the entity, then fire retained listeners. | Disposal clears state and the retained callbacks become inert instead of throwing (the event handler has no unsubscribe API). |
shouldBeSafeBeforeCreationAndWithoutCombatStats |
Query and cast before create(), and on an entity with no CombatStatsComponent. |
Every call is safe and refuses rather than throwing. |
shouldResolveRegisteredClockAtCreationAndRetainIt |
Create with no injected clock. | The registered GameTime is resolved once at creation and retained. |
shouldPreferInjectedClockOverRegisteredClockAndAllowCastingAtTimeZero |
Inject a clock reading 0. | The injected clock wins, and a cast at t = 0 is allowed rather than being read as "already on cooldown". |
shouldRunACastAbilityAddedWithoutChangingTheComponent |
Register a brand-new cast ability. | It casts, cools down and fires events with no change to the component โ the extension point works. |
shouldTriggerAnAddedPassiveFromDamageAlongsideLastStand |
Register a second passive and take a qualifying hit. | Both passives start from the same hit, independently. |
shouldGiveAnAbilityItsOwnerSoItCanActOnOtherEntities |
Register an ability that reads getOwner(). |
The owner is set on registration, so an ability can reach the world (e.g. effects on nearby enemies). |
shouldRunAnInstantAbilityThatAppliesNoStatusEffect |
Register an ability extending PlayerAbility directly. |
It runs and cools down without ever touching the status effects system. |
AbilityCommandTest
ability is the normal gameplay entry point, not a cheat: it goes through the component, so it can fail.
| Test | Purpose | Expected Result |
|---|---|---|
shouldRejectNullEmptyExtraAndUnknownArgumentsWithoutActivatingAbilities |
Run with null, no args, two args and an unknown name. |
All rejected, and nothing is activated as a side effect. |
shouldRejectMissingPlayerOrAbilitiesForBothCommands |
Run against a null player and a player with no abilities component. | Rejected rather than throwing. |
shouldCastInvisibilityWithoutBypassingOrResettingCooldown |
Cast twice in a row. | The second is refused โ the command honours the cooldown instead of going around it. |
shouldEnableLastStandIdempotentlyWithoutDirectActivationOrCooldownBypass |
Run ability laststand repeatedly. |
Unlocking is idempotent and never directly starts the passive or resets its cooldown. |
shouldRejectDeadPlayerCastAndNotEnablePassiveWhileDead |
Run both commands on a dead player. | Both refused. |
shouldNotActivateDisposedOrUncreatedAbilitiesOrAbilitiesWithoutStats |
Run against disposed, uncreated and stat-less components. | Refused in every case. |
Spells
SpellComponentTest
Everything every spell shares: construction, cooldown, targeting hand-off, the area disc, and applying effects to targets that may not be able to carry one. The tests use a stub subclass, so they hold for any future spell too.
| Test | Purpose | Expected Result |
|---|---|---|
rejectsBeingBuiltWithoutTheThingsACastNeeds |
Construct with a negative cooldown, and with a null event, colour or strategy. | IllegalArgumentException in every case. |
aZeroCooldownSpellIsAlwaysReady |
Cast a zero-cooldown spell repeatedly. | canCast() is always true. |
landsOnEveryTargetTheStrategyPicked |
Cast with several targets selected. | The effect is applied to each one. |
landingOnNothingIsStillACast |
Cast with an empty target list. | The cast succeeds and starts the cooldown โ a miss still costs. |
asksTheStrategyAboutTheCasterItIsAttachedTo |
Cast from a component on an entity. | The strategy is asked about that entity, not some other one. |
castingAgainDuringTheCooldownDoesNothingAtAll |
Cast twice without ticking. | The second returns false, selects no targets and applies nothing. |
becomesReadyAgainOnceTheCooldownHasBeenTickedThrough |
Tick the full cooldown, then cast. | Ready again at the end of the cooldown. |
aBadTimeStepNeitherStallsTheCooldownNorRunsItBackwards |
Feed negative and oversized frame times. | The countdown clamps at zero and never runs backwards. |
anIdleSpellDoesNotKeepAskingForTheTime |
Tick an off-cooldown spell. | It stops querying the clock โ no per-frame cost when idle. |
itsOwnCastEventFiresIt |
Trigger the component's cast event. | The spell casts. |
showsTheAreaItCoveredInItsOwnColourAndTheStrategysRadius |
Cast with an area visual attached. | The disc is shown in the spell's colour at the strategy's radius, so the circle drawn is the circle hit. |
castingWithoutAnAreaVisualStillLands |
Cast on an entity with no SpellAoeVisualComponent. |
The effect still lands; the visual is optional. |
swapsTargetingBehaviourWithoutTouchingTheEffect |
Replace the strategy at runtime. | Targeting changes, the effect does not โ targeting and effect stay decoupled. |
refusesATargetingStrategyOfNull |
Set a null strategy. | IllegalArgumentException. |
putsTheEffectOnATargetThatCanCarryOne |
Cast at a target with a status effects controller. | The effect is added. |
skipsATargetThatCannotCarryAnEffectRatherThanThrowing |
Cast at a target with no controller. | Skipped quietly. |
skipsATargetWhoseControllerHasAlreadyBeenDisposed |
Cast at a target mid-teardown. | Skipped, so a cast during a room transition cannot crash. |
LightningSpellComponentTest
| Test | Purpose | Expected Result |
|---|---|---|
damagesEveryEnemyItCatches |
Cast over several enemies. | Each loses the spell's damage. |
canFinishOffAnEnemyOutright |
Strike an enemy on low health. | It dies. |
flashesEachStruckEnemyPurple |
Cast and inspect the tint. | Each struck enemy is tinted purple. |
alsoGlowsSoTheFlashShowsOnANearBlackEnemy |
Strike a very dark sprite. | A purple glow is laid on top, so the hit still reads โ a multiply-only tint would be invisible here. |
theFlashIsBriefAndLeavesTheEnemyLookingNormalAgain |
Run the flash out. | The tint comes off after 400 ms. |
leavesTheStruckEnemyFreeToKeepFighting |
Check stats after the strike. | Lightning damages but never immobilises or debuffs. |
skipsSomethingWithNoHealthToTakeRatherThanThrowing |
Catch a target with no CombatStatsComponent. |
Skipped quietly. |
doesNotStrikeAgainWhileOnCooldown |
Cast twice in a row. | The second does nothing. |
isCastByItsOwnEvent |
Trigger castLightning. |
The spell casts. |
rejectsNonsensicalDamageOrFlashLengths |
Construct with negative damage or flash duration. | IllegalArgumentException. |
aZeroDamageBoltStillFlashesTheEnemy |
Cast a 0-damage bolt. | The flash still plays, so feedback never depends on the damage roll. |
The bolt deals unattributed damage โ it does not name the caster, so a struck enemy does not treat it as a melee hit from the player.
FreezeSpellComponentTest
| Test | Purpose | Expected Result |
|---|---|---|
pinsEveryEnemyItCatches |
Cast over several enemies. | Each is immobilised for the duration. |
turnsThemLightBlueAndGlowingSoTheFreezeShowsOnAnySprite |
Inspect tint and glow. | Light blue tint plus glow, so the freeze reads on dark sprites too. |
aFrozenEnemyCannotHurtAnythingOnContactEither |
Walk into a frozen enemy. | Its stats are zeroed, so contact damage lands for nothing. |
thawsOnceTheFreezeRunsOutAndIsWholeAgain |
Run the freeze out. | Movement, attack and appearance all return to normal. |
doesNoDamageOfItsOwn |
Check health after the cast. | Freeze is pure control โ no damage. |
skipsATargetThatCannotCarryAnEffect |
Catch a target with no controller. | Skipped quietly. |
doesNotFreezeAgainWhileOnCooldown |
Cast twice in a row. | The second does nothing. |
isCastByItsOwnEvent |
Trigger castFreeze. |
The spell casts. |
lightningAndFreezeListenOnDifferentEventsSoOneCastIsNotBoth |
Fire each event with both spells attached. | Only the matching spell responds โ casting one never fires the other. |
rejectsNonsensicalArguments |
Construct with a negative duration or cooldown, or a null strategy. | IllegalArgumentException. |
SpellAoeVisualComponentTest
One disc serves every spell on the caster; each cast says what colour and how far it reached.
| Test | Purpose | Expected Result |
|---|---|---|
drawsNothingUntilASpellIsCast |
Render before any cast. | Nothing is drawn. |
anIdleDiscDoesNotKeepAskingForTheTime |
Tick while idle. | The clock is not queried โ no per-frame cost. |
drawsTheDiscCentredOnTheCasterAndSizedToTheRadius |
Cast and inspect the draw call. | Centred on the caster, with a diameter of twice the radius. |
drawsInTheColourTheCastAskedForRatherThanWhateverTheBatchWasSetTo |
Draw with a pre-set batch colour. | The cast's colour is set, not multiplied โ the caster's own damage flash never tints the disc. |
restoresTheBatchColourAfterwardsSoTheNextSpriteIsUnaffected |
Draw, then inspect the batch. | The previous packed colour is restored, including on an exception path. |
fadesOutAndThenStopsDrawingAltogether |
Advance through the 0.55 s fade. | Alpha falls with the fade, then drawing stops. |
aRadiuslessStrategyDrawsNoCircleAtAll |
Cast through an unbounded strategy (radius 0). | No disc โ an unbounded spell has no circle to honestly draw. |
aSecondCastReplacesTheFadeOfTheFirst |
Cast during another cast's fade. | The new disc replaces the old, which is what the player expects to see. |
survivesAFrameWithNoUsableTimeStep |
Feed a zero, negative or non-finite delta. | The fade holds rather than jumping or stalling forever. |
sitsBehindEverySpriteSoItReadsAsPaintedOnTheFloor |
Read the z-index. | Behind every sprite in the layer. |
disposingTwiceIsHarmless |
Dispose twice. | No double-free of the generated texture. |
Targeting
Targeting is fully decoupled from spell effects, so these tests protect both spells and any future one at once.
EnemyUtilsTest
| Test | Purpose | Expected Result |
|---|---|---|
treatsAnEntityOnTheNpcLayerAsAnEnemy |
Check an NPC-layer hitbox. | Recognised as an enemy. |
treatsAnEntityOnAnotherLayerAsNotAnEnemy |
Check a non-NPC layer. | Not an enemy โ spells never catch the player or scenery. |
treatsAnEntityWithoutAHitboxAsNotAnEnemy |
Check an entity with no hitbox. | Not an enemy. |
treatsAHitboxWithNoFixtureYetAsNotAnEnemy |
Check a hitbox before physics has built its fixture. | Not an enemy โ casting on the first frame of a room cannot throw. |
recognisesAnEnemyOnSeveralLayersAtOnce |
Check a multi-layer hitbox including NPC. | Still an enemy. |
cannotBeInstantiated |
Construct the utility class. | Throws โ it is static-only. |
StrategyAllTest
| Test | Purpose | Expected Result |
|---|---|---|
returnsEveryEnemyRegardlessOfDistance |
Register enemies near and far. | All are returned. |
excludesTheCasterEvenWhenItIsItselfOnTheEnemyLayer |
Cast from an entity on the NPC layer. | The caster is never its own target. |
excludesEntitiesThatAreNotEnemies |
Register non-enemies. | Excluded. |
returnsEmptyRatherThanNullWhenTheWorldHoldsNoEnemies |
Select with nothing registered. | Empty array, never null. |
reportsNoRadiusBecauseItIsNotBoundedByOne |
Read getRadius(). |
0 โ so a spell using it draws no circle. |
StrategyClosestTest
| Test | Purpose | Expected Result |
|---|---|---|
returnsOnlyTheNearestEnemy |
Register several enemies. | Exactly one target, the nearest. |
measuresDistanceFromTheCasterRatherThanTheOrigin |
Cast from away from the origin. | Distance is measured from the caster's centre. |
keepsTheFirstEnemyEncounteredWhenTwoAreEquallyClose |
Register a tie. | Deterministic โ the first encountered wins. |
ignoresNonEnemiesEvenWhenTheyAreCloser |
Put a non-enemy nearest. | Ignored. |
excludesTheCasterEvenWhenItIsTheNearestThingOnTheEnemyLayer |
Cast from an NPC-layer entity. | The caster is excluded. |
returnsEmptyWhenThereIsNothingToTarget |
Select with no enemies. | Empty array. |
reportsNoRadiusBecauseItIsNotBoundedByOne |
Read getRadius(). |
0. |
StrategyWithinRadiusTest
This is the strategy both shipped spells use.
| Test | Purpose | Expected Result |
|---|---|---|
selectsOnlyTheEnemiesInsideTheRadius |
Register enemies in and out of range. | Only those inside. |
measuresFromTheCasterRatherThanTheOrigin |
Cast away from the origin. | Measured from the caster's centre. |
excludesAnEnemyExactlyOnTheEdge |
Place an enemy exactly at the radius. | Excluded โ the boundary is strict, matching the drawn circle. |
measuresDiagonallyRatherThanPerAxis |
Place an enemy diagonally inside the bounding box but outside the circle. | Excluded โ it is a circle, not a square. |
excludesTheCasterAndEverythingThatIsNotAnEnemy |
Register the caster and non-enemies in range. | Excluded. |
defaultsToATenUnitRadius |
Use the no-arg constructor. | Radius is 10 world units. |
reportsTheRadiusItWasBuiltWithSoASpellCanDrawTheSameCircleItHits |
Read getRadius(). |
The construction radius, which is what the area disc draws. |
rejectsANegativeRadius |
Construct with a negative radius. | IllegalArgumentException. |
aZeroRadiusHitsNothing |
Select with radius 0. | No targets. |
theOneOffOverloadUsesTheRadiusPassedInsteadOfItsOwn |
Call selectTargets(caster, radius). |
The passed radius wins for that call only. |
StrategyOnscreenTest
| Test | Purpose | Expected Result |
|---|---|---|
selectsAnEnemyWellInsideTheViewport |
Place an enemy on screen. | Selected. |
excludesAnEnemyPastTheEdgeOfTheViewport |
Place an enemy off screen. | Excluded. |
includesAnEnemyExactlyOnTheViewportBoundary |
Place an enemy exactly on the edge. | Included โ if the player can see it, it can be hit. |
followsTheCameraRatherThanTheWorldOrigin |
Move the camera. | Visibility is judged against the camera, not the origin. |
judgesEachEnemyIndependentlyRatherThanCorruptingLaterReads |
Select with several enemies. | Each is judged from its own position โ the shared vector maths does not leak between them. |
excludesTheCasterAndOffLayerEntitiesEvenWhenTheyAreOnScreen |
Put the caster and non-enemies on screen. | Excluded. |
rejectsBeingBuiltWithoutACamera |
Construct with a null camera. | IllegalArgumentException. |
reportsNoRadiusBecauseAViewportIsNotACircle |
Read getRadius(). |
0 โ a rectangle has no honest circle to draw. |
Player Wiring and Commands
PlayerFactorySpellsTest
| Test | Purpose | Expected Result |
|---|---|---|
carriesBothSpellsAndTheDiscTheyShareToShowTheirArea |
Build a player. | Lightning, freeze and one shared SpellAoeVisualComponent are attached. |
bothSpellsStartReadyToCast |
Inspect a fresh player. | Neither starts on cooldown. |
givesEachPlayerItsOwnSpellsRatherThanSharingOneCooldown |
Build two players and cast on one. | The other is unaffected โ no static state. |
castingOneSpellDoesNotPutTheOtherOnCooldown |
Cast lightning, then freeze. | Cooldowns are per spell. |
bothSpellsCoverTheSameAreaSoThePlayerLearnsOneRange |
Compare the two radii. | Identical, so the player learns one range for both. |
SpellCommandTest
| Test | Purpose | Expected Result |
|---|---|---|
castsLightning / castsFreeze |
Run each command. | The matching spell casts. |
castingOneSpellLeavesTheOtherReady |
Cast one, then the other. | The second still casts. |
reportsFailureWhileTheSpellIsStillOnCooldown |
Cast twice in a row. | The command reports failure rather than firing anyway. |
succeedsAgainOnceTheCooldownHasRunDown |
Tick the cooldown, then recast. | Succeeds. |
rejectsASpellNameItDoesNotKnow |
Run spell fireball. |
Rejected. |
rejectsTheWrongNumberOfArguments |
Run with no args and with two. | Rejected. |
survivesBeingCalledWithNothingToWorkWith |
Run with a null player or null args. | Rejected rather than throwing. |
reportsFailureWhenThePlayerSimplyDoesNotHaveThatSpell |
Run against a player without that spell component. | Rejected. |
Manual Gameplay Test Plan
Setup for every test below: run the game, enter a dungeon room with enemies, open the debug terminal (F1), and use the commands above. Abilities and spells are not yet bound to keys, so the terminal stays open between casts.
Invisibility
| Test | Steps | Expected Result |
|---|---|---|
| Player reads as hidden | Cast ability invisibility. |
The player sprite is drawn faded, so the state is obvious without checking the HUD. |
| Enemies break off | Cast while being chased. | Chasing enemies lose the player and stop pursuing. |
| No contact damage | Stand in a crowd of enemies while hidden. | The player takes no damage and is not shoved. |
| Bombs do not fuse | Stand on a bomb enemy while hidden, then wait for invisibility to expire. | The fuse does not light while hidden; it lights the moment the ability ends. |
| Expires cleanly | Watch the player at the 15 s mark. | The fade lifts and enemies immediately reacquire the player. |
| Cooldown is honoured | Cast again straight after it ends. | The terminal reports failure until 45 s from the start of the previous cast. |
Last Stand
| Test | Steps | Expected Result |
|---|---|---|
| Locked by default | Drop below 20% health without unlocking. | Nothing happens. |
| Unlock then trigger | Run ability laststand, then take hits down to under 20% health. |
The player turns red and strength, movement speed and attack speed all climb. |
| Buff is real in combat | Attack an enemy while the buff is up. | Weapon hits land for more and swing faster than the same weapon did before. |
| Exactly 20% does nothing | Take a hit landing exactly on 20%. | No trigger โ one more point of damage is needed. |
| Reverts on expiry | Wait out the 10 s. | Tint and stats return to their previous values. |
| Death resets it | Die, then respawn. | The passive is locked again and must be unlocked afresh. |
Lightning
| Test | Steps | Expected Result |
|---|---|---|
| Area is visible | Run spell lightning. |
A purple disc is drawn on the floor around the player and fades over about half a second. |
| The disc matches the hit | Cast with enemies just inside and just outside the disc. | Exactly the enemies inside the circle flash and take damage. |
| Damage lands | Cast beside a full-health enemy. | It loses 25 health. |
| Kills work | Cast on an enemy under 25 health. | It dies without crashing the game. |
| Flash reads on dark sprites | Cast on a dark enemy. | It still visibly flashes purple. |
| Enemies keep fighting | Watch a struck enemy. | It keeps moving and attacking โ lightning does not control. |
| Cooldown is honoured | Cast twice in a row. | The second is refused until 5 s have passed. |
| The disc is under sprites | Cast in a crowd. | The disc is painted under every sprite, not over them. |
Freeze
| Test | Steps | Expected Result |
|---|---|---|
| Area is visible | Run spell freeze. |
A light blue disc is drawn around the player and fades. |
| Enemies are pinned | Cast on chasing enemies. | Each stops dead, tinted light blue, for 5 s. |
| Frozen enemies are harmless | Walk into a frozen enemy. | It deals no contact damage. |
| No damage | Watch health bars through the freeze. | Frozen enemies lose no health. |
| Thaw is clean | Wait out the 5 s. | Enemies resume chasing at normal speed and the tint lifts. |
| Cooldown is honoured | Cast twice in a row. | The second is refused until 5 s have passed. |
| Spells are independent | Cast freeze, then lightning immediately. | Lightning still fires โ the cooldowns are separate. |
Combined
| Test | Steps | Expected Result |
|---|---|---|
| Freeze then strike | Freeze a group, then cast lightning on them. | Frozen enemies take the damage and stay frozen; the second disc replaces the first. |
| Hidden and casting | Cast invisibility, then a spell. | Spells still land and enemies still cannot find the player โ casting does not break concealment. |
| Last Stand escape | Trigger Last Stand at low health, then freeze the room. | Both run at once, on their own timers, and the escape works. |
Known Coverage Gaps
Three lines in the two abstract base classes are not covered, all of them fallbacks that no shipped ability reaches:
| Location | Uncovered | Why |
|---|---|---|
PlayerAbility.isCastable() |
The default return false |
Both shipped abilities are reached through paths that never hit the base default: Invisibility overrides it, and Last Stand is unlocked rather than cast. It is exercised indirectly through the added-ability tests. |
TimedPlayerAbility.getEffect() |
The whole accessor | A protected hook for subclasses that need to read their own live effect. Neither Invisibility nor Last Stand does. |
TimedPlayerAbility.start() |
The effects == null early return |
Guards an ability started before it has been attached to a controller. PlayerAbilitiesComponent always attaches on registration, so production never reaches it. |
These are the only gaps; everything else in the feature is at 100% line and branch coverage.
Areas Not Covered by Automated Tests
- Visual correctness. The rendering tests assert the arguments handed to the sprite batch, not pixels. Whether the disc reads as painted on the floor, whether the purple flash is legible against a given enemy, and whether the fade feels right are judged manually.
- Tuning. Radius, damage, durations and cooldowns are asserted as whatever they were constructed with; whether 3 units / 25 damage / 5 s is the right value is a balance judgement made in playtesting.
- Input. Abilities and spells have no keybinding yet, so there is no
KeyboardPlayerInputComponentcoverage for them. Both are reached through the debug terminal, whose parsing is manual; the command classes themselves are fully covered. - No HUD for spells. There is no cooldown indicator, so a player cannot currently see when a spell is ready โ a refused cast is silent in game. Reported as failure by the terminal only.
StrategyAllacross rooms. It selects every enemy registered with theEntityService. That is correct while only one room's entities are registered at a time; if that ever changes it would reach into other rooms. Neither shipped spell uses it.- Enemy-side reactions to concealment and immobilisation (chase, lunge, ranged and bomb behaviour) are covered by the enemy AI test classes listed on Player Abilities and Status Effects, not repeated here.