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, TimedPlayerAbility and PlayerAbilitiesComponent, which owns cooldowns, the alive/unlock gating and the abilityUsed / abilityEnded / abilityFailed events (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 by SpellAoeVisualComponent.
  • The two spells: Lightning and Freeze.
  • The five targeting strategies that decide who a spell catches, and the EnemyUtils layer check behind them.
  • Player wiring (PlayerFactory) and the two debug-terminal entry points, ability and spell.

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.

Quality Gate Coverage

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 KeyboardPlayerInputComponent coverage 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.
  • StrategyAll across rooms. It selects every enemy registered with the EntityService. 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.

Related pages