Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/DefenseTowerVisualPlayModeTests.cs
T
2026-09-27 21:08:05 +09:00

991 lines
54 KiB
C#

using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Enemies;
using BumpCombat.Progression;
using BumpCombat.Player;
using BumpCombat.UI;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace BumpCombat.PlayModeTests
{
public sealed class DefenseTowerVisualPlayModeTests
{
private const string SceneName = "CombatPrototype";
private const string StartupSelectionPanelName = "Artifact Reward Selection";
private const float ReadyTimeoutSeconds = 8f;
private float originalTimeScale;
private Camera captureCamera;
private ArenaCameraFollow captureCameraFollow;
private Vector3 originalCameraPosition;
private float originalCameraOrthographicSize;
private bool originalCameraFollowEnabled;
private bool cameraStateSaved;
#if UNITY_EDITOR
private bool originalGrantCatalogForTests;
private bool originalForceProductionModeForTests;
#endif
[SetUp]
public void SetUp()
{
originalTimeScale = Time.timeScale;
Time.timeScale = 1f;
DefensePrototypeController.CancelTowerHitTestRun();
#if UNITY_EDITOR
originalGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests;
originalForceProductionModeForTests = RunManager.ForceProductionModeForTests;
ArtifactRewardController.GrantCatalogForTests = false;
RunManager.ForceProductionModeForTests = false;
#endif
}
[TearDown]
public void TearDown()
{
DefensePrototypeController.CancelTowerHitTestRun();
RunManager runManager = RunManager.Instance;
if (runManager != null) runManager.ClosePause();
GameObject selectionPanel = GameObject.Find(StartupSelectionPanelName);
if (selectionPanel != null) selectionPanel.SetActive(false);
#if UNITY_EDITOR
ArtifactRewardController.GrantCatalogForTests = originalGrantCatalogForTests;
RunManager.ForceProductionModeForTests = originalForceProductionModeForTests;
#endif
if (cameraStateSaved && captureCamera != null)
{
captureCamera.transform.position = originalCameraPosition;
captureCamera.orthographicSize = originalCameraOrthographicSize;
if (captureCameraFollow != null)
captureCameraFollow.enabled = originalCameraFollowEnabled;
}
Time.timeScale = originalTimeScale;
}
[UnityTest]
public IEnumerator ColdTower_TiersFireSequentialSingleProjectilesWithPerShotImpacts()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != cold) tower.enabled = false;
Assert.That(target.Enemy.TryReposition(cold.transform.position), Is.True);
Physics2D.SyncTransforms();
Sprite[] volleyFrames = DefenseTowerArt.Load("Effects/Ice/T1-Volley");
Sprite[] freezeboltFrames = DefenseTowerArt.Load("Effects/Ice/Freezebolt");
Assert.That(controller.Tuning.ColdProjectileBurstInterval, Is.EqualTo(0.12f));
yield return AssertColdVolley(cold, target, volleyFrames, 1,
controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
for (int tier = 2; tier <= 3; tier++)
{
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.None), Is.True);
yield return AssertColdVolley(cold, target, volleyFrames, tier,
controller.Tuning.ColdProjectileBurstInterval, faceLeft: tier == 2);
}
Assert.That(player.TryReposition(controller.Tuning.GetBuildPadPosition(cold.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(cold.PadIndex, DefenseTowerBranch.First), Is.True);
Assert.That(controller.Tuning.FreezeboltProjectileCount, Is.EqualTo(5));
yield return AssertColdVolley(cold, target, freezeboltFrames,
5, controller.Tuning.ColdProjectileBurstInterval, faceLeft: true);
}
[UnityTest]
public IEnumerator FireAndLightningProjectiles_StartAtStaffTipWhenFacingEitherDirection()
{
yield return StartTowerHitTestRun();
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != fire && tower != lightning) tower.enabled = false;
SetField(fire, "attackCooldown", 60f);
SetField(lightning, "attackCooldown", 60f);
yield return AssertStaffTipOriginsBothSides(fire, FindDummy(fire.Kind));
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
Assert.That(player.TryReposition(
DefensePrototypeController.Instance.Tuning.GetBuildPadPosition(lightning.PadIndex)),
Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
Assert.That(DefensePrototypeController.Instance.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.First), Is.True);
yield return AssertStaffTipOriginsBothSides(lightning, FindDummy(lightning.Kind));
}
[UnityTest]
public IEnumerator Blizzard_AutomaticallyHitsTheUnmovedColdDummy()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
tower.enabled = false;
SetField(cold, "attackCooldown", 60f);
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
SetField(cold, "attackCooldown", -0.1f);
cold.enabled = true;
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
healthField.SetValue(target.Enemy, 10000f);
Vector2 originalRoot = target.transform.position;
Vector2 originalShadow = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, originalRoot);
List<(float damage, EnemyDamageSource source)> damageEvents = new();
System.Action<float, float> observer = (_, damage) =>
damageEvents.Add((damage, target.Enemy.LastDamageSource));
target.Enemy.OnDamageApplied += observer;
yield return new WaitForSeconds(0.9f);
float nearestEllipseDistance = float.PositiveInfinity;
for (int i = 0; i < DefenseTowerArtBounds.BlizzardShardCount; i++)
{
Vector2 landing = originalRoot
+ DefenseTowerArtBounds.BlizzardLandingOffset(i, 1.65f);
Vector2 delta = originalShadow - landing;
float ellipseDistance = delta.x * delta.x
/ (DefenseTowerArtBounds.BlizzardWaveRadiusX
* DefenseTowerArtBounds.BlizzardWaveRadiusX)
+ delta.y * delta.y
/ (DefenseTowerArtBounds.BlizzardWaveRadiusY
* DefenseTowerArtBounds.BlizzardWaveRadiusY);
nearestEllipseDistance = Mathf.Min(nearestEllipseDistance, ellipseDistance);
}
Assert.That(damageEvents.Count, Is.GreaterThan(0),
$"Automatic Blizzard should hit its selected unmoved target. root={originalRoot}, "
+ $"shadow={originalShadow}, castCenter={originalRoot}, "
+ $"nearestEllipseDistance={nearestEllipseDistance:F4}.");
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(Vector2.Distance(target.transform.position, originalRoot),
Is.LessThan(0.001f),
"The dummy remains at its original test-scene position.");
target.Enemy.OnDamageApplied -= observer;
}
[UnityTest]
public IEnumerator Blizzard_DamagesEachLandingEllipseAndHitsOverlapsSeparately()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None))
tower.enabled = false;
SetField(cold, "attackCooldown", 60f);
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
SetField(cold, "attackCooldown", 60f);
cold.enabled = true;
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Cold);
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
healthField.SetValue(target.Enemy, 10000f);
Vector2 originalRoot = target.transform.position;
SpriteRenderer targetRenderer = target.Enemy.GetComponent<SpriteRenderer>();
targetRenderer.flipX = false;
Vector2 shadowOffset = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, originalRoot) - originalRoot;
List<(float time, float damage, EnemyDamageSource source)> damageEvents = new();
System.Action<float, float> damageObserver = (_, appliedDamage) =>
damageEvents.Add((Time.time, appliedDamage, target.Enemy.LastDamageSource));
target.Enemy.OnDamageApplied += damageObserver;
float expectedDamage = Mathf.Floor(controller.Tuning.ColdDamage
* (1f + controller.Tuning.TowerDamageBonusPerTier * (cold.Tier - 1)) * 0.5f);
Vector2 singleAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Vector2 singleWaveGround = singleAttackCenter
+ DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
float singleStartHealth = target.Enemy.CurrentHealth;
float singleStartedAt = Time.time;
Assert.That(cold.TryPulse(), Is.True);
Vector2 singleRoot = singleWaveGround - shadowOffset;
Assert.That(target.Enemy.TryReposition(singleRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", singleRoot);
Physics2D.SyncTransforms();
float singleHitDeadline = Time.realtimeSinceStartup + 0.8f;
while (damageEvents.Count == 0 && Time.realtimeSinceStartup < singleHitDeadline)
yield return null;
Assert.That(damageEvents.Count, Is.EqualTo(1),
"A target at one landing center is hit once by that wave only.");
Assert.That(damageEvents[0].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(damageEvents[0].damage, Is.EqualTo(expectedDamage).Within(0.001f));
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
Assert.That(target.Enemy.IsStunned, Is.True);
float singleRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
- (Time.time - singleStartedAt);
if (singleRemaining > 0f) yield return new WaitForSeconds(singleRemaining);
Assert.That(damageEvents.Count, Is.EqualTo(1),
"The wave never repeats its hit while its expansion animation plays.");
Assert.That(singleStartHealth - target.Enemy.CurrentHealth,
Is.EqualTo(expectedDamage).Within(0.001f));
damageEvents.Clear();
healthField.SetValue(target.Enemy, 10000f);
SetField(cold, "attackCooldown", 60f);
Vector2 overlapAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Vector2 first = DefenseTowerArtBounds.BlizzardLandingOffset(0, 1.65f);
Vector2 second = DefenseTowerArtBounds.BlizzardLandingOffset(1, 1.65f);
Vector2 overlapGround = overlapAttackCenter + (first + second) * 0.5f;
float overlapStartedAt = Time.time;
Assert.That(cold.TryPulse(), Is.True);
Vector2 overlapRoot = overlapGround - shadowOffset;
Assert.That(target.Enemy.TryReposition(overlapRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", overlapRoot);
Physics2D.SyncTransforms();
float overlapDeadline = Time.realtimeSinceStartup + 0.8f;
while (damageEvents.Count < 2 && Time.realtimeSinceStartup < overlapDeadline)
yield return null;
Assert.That(damageEvents.Count, Is.EqualTo(2),
"Only the two overlapping landing ellipses hit this target.");
for (int i = 0; i < damageEvents.Count; i++)
{
Assert.That(damageEvents[i].source, Is.EqualTo(EnemyDamageSource.Tower));
Assert.That(damageEvents[i].damage, Is.EqualTo(expectedDamage).Within(0.001f),
$"Wave event {i} applies the configured per-wave damage.");
}
Assert.That(target.Enemy.MovementSpeedMultiplier, Is.LessThan(1f));
Assert.That(target.Enemy.IsStunned, Is.True);
float overlapRemaining = DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f
- (Time.time - overlapStartedAt);
if (overlapRemaining > 0f) yield return new WaitForSeconds(overlapRemaining);
Assert.That(damageEvents.Count, Is.EqualTo(2),
"Neither overlapping wave repeats during its animated lifetime.");
Assert.That(10000f - target.Enemy.CurrentHealth,
Is.EqualTo(expectedDamage * 2f).Within(0.001f));
damageEvents.Clear();
healthField.SetValue(target.Enemy, 10000f);
SetField(cold, "attackCooldown", 60f);
Vector2 outerAttackCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
target.Enemy, target.transform.position);
Assert.That(cold.TryPulse(), Is.True);
Vector2 outerRoot = outerAttackCenter + new Vector2(1.5f, 0f) - shadowOffset;
Assert.That(target.Enemy.TryReposition(outerRoot), Is.True);
SetField(target, "stationaryTowerTestDummyAnchor", outerRoot);
Physics2D.SyncTransforms();
yield return new WaitForSeconds(DefenseTowerArtBounds.BlizzardLastLandingSeconds + 0.1f);
Assert.That(damageEvents.Count, Is.Zero,
"A target outside every wave ellipse but inside the cast radius is not hit.");
Assert.That(target.Enemy.CurrentHealth, Is.EqualTo(10000f).Within(0.001f));
target.Enemy.OnDamageApplied -= damageObserver;
}
private static IEnumerator AssertStaffTipOriginsBothSides(
DefenseTower tower, DefenseRouteAgent target)
{
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
foreach (bool faceLeft in new[] { true, false })
{
healthField.SetValue(target.Enemy, 10000f);
Vector2 targetPosition = (Vector2)tower.transform.position
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
Physics2D.SyncTransforms();
SetField(tower, "attackCooldown", 60f);
string effectName = tower.Kind == DefenseTowerKind.Direct
? "Direct Attack Effect" : "Lightning Attack Effect";
HashSet<int> previousEffectIds = new();
foreach (Transform existing in Object.FindObjectsByType<Transform>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
if (existing.name == effectName)
previousEffectIds.Add(existing.gameObject.GetInstanceID());
Assert.That(tower.TryPulse(), Is.True);
GameObject effect = null;
foreach (Transform candidate in Object.FindObjectsByType<Transform>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (candidate.name == effectName
&& !previousEffectIds.Contains(candidate.gameObject.GetInstanceID()))
{
effect = candidate.gameObject;
break;
}
}
Assert.That(effect, Is.Not.Null);
Transform shot = effect.transform.Find("Shot Sprite");
Assert.That(shot, Is.Not.Null);
SpriteRenderer renderer = shot.GetComponent<SpriteRenderer>();
Vector2 expectedStart = GetStaffTipWorldPosition(tower);
if (tower.Kind == DefenseTowerKind.Direct)
{
Vector2 travelStart = (Vector2)GetSequenceField(
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
Assert.That(Vector2.Distance(travelStart, expectedStart),
Is.LessThan(0.0001f),
$"Fire T{tower.Tier}, flipX={faceLeft}, travel start must match the attack-frame staff tip.");
Assert.That(Vector2.Distance(shot.position, expectedStart),
Is.LessThan(0.0001f), "Fireball renderer starts at the staff tip.");
}
else
{
string key = tower.Tier == 4
? "Effects/Lightning/Arc" : "Effects/Lightning/T1-Bolt";
int frame = FindFrameIndex(DefenseTowerArt.Load(key), renderer.sprite);
Vector2Int bounds = DefenseTowerArtBounds.LightningBeamXBounds(frame);
Vector3 localStart = new(
(bounds.x - renderer.sprite.pivot.x) / renderer.sprite.pixelsPerUnit,
0f, 0f);
Assert.That(Vector2.Distance(
renderer.transform.TransformPoint(localStart), expectedStart),
Is.LessThan(0.0001f),
$"Lightning T{tower.Tier} renderer starts at the staff tip.");
}
yield return null;
}
}
private static IEnumerator AssertColdVolley(
DefenseTower tower, DefenseRouteAgent target, Sprite[] expectedFrames,
int expectedCount, float burstInterval, bool faceLeft = false)
{
FieldInfo healthField = typeof(EnemyController).GetField(
"<CurrentHealth>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(healthField, Is.Not.Null);
healthField.SetValue(target.Enemy, 10000f);
Vector2 targetPosition = (Vector2)tower.transform.position
+ (faceLeft ? Vector2.left : Vector2.right) * 1.25f;
Assert.That(target.Enemy.TryReposition(targetPosition), Is.True);
Physics2D.SyncTransforms();
float healthBefore = target.Enemy.CurrentHealth;
SetField(tower, "attackCooldown", 60f);
Assert.That(tower.TryPulse(), Is.True);
GameObject effect = GameObject.Find("Cold Attack Effect");
Assert.That(effect, Is.Not.Null);
SpriteRenderer[] renderers = effect.GetComponentsInChildren<SpriteRenderer>(true);
List<SpriteRenderer> shots = new();
foreach (SpriteRenderer renderer in renderers)
{
if (!renderer.name.StartsWith("Shot Sprite ")) continue;
shots.Add(renderer);
Assert.That(renderer.sprite, Is.SameAs(expectedFrames[0]),
"Every projectile must reuse one existing projectile sheet.");
}
Assert.That(shots.Count, Is.EqualTo(expectedCount));
AssertStaffTipStart(tower, shots[0]);
for (int i = 0; i < shots.Count; i++)
Assert.That(shots[i].enabled, Is.EqualTo(i == 0),
"Only the first projectile is visible at launch.");
HashSet<int> impactIds = new();
HashSet<int> hiddenShotsAtImpact = new();
Dictionary<int, float> launchTimes = new();
bool flightRendererHiddenAtImpact = false;
float previousHealth = healthBefore;
int damageEvents = 0;
float deadline = Time.realtimeSinceStartup + 1.5f;
while (Time.realtimeSinceStartup < deadline && effect != null)
{
float now = Time.realtimeSinceStartup;
for (int i = 0; i < shots.Count; i++)
if (shots[i] != null && shots[i].enabled && !launchTimes.ContainsKey(i))
{
launchTimes.Add(i, now);
AssertStaffTipStart(tower, shots[i]);
}
foreach (Transform child in effect.transform)
if (child.name == "Impact Sprite")
impactIds.Add(child.gameObject.GetInstanceID());
if (impactIds.Count > 0 && (shots[0] == null || !shots[0].enabled))
flightRendererHiddenAtImpact = true;
for (int i = 0; i < shots.Count; i++)
if (impactIds.Count > i && (shots[i] == null || !shots[i].enabled))
hiddenShotsAtImpact.Add(i);
float currentHealth = target.Enemy.CurrentHealth;
if (currentHealth < previousHealth)
{
damageEvents++;
previousHealth = currentHealth;
}
yield return null;
}
Assert.That(launchTimes.Count, Is.EqualTo(expectedCount),
"Each projectile renderer must become visible in sequence.");
for (int i = 1; i < expectedCount; i++)
Assert.That(launchTimes[i] - launchTimes[i - 1], Is.EqualTo(burstInterval).Within(0.035f));
Assert.That(impactIds.Count, Is.EqualTo(expectedCount),
"Each projectile must create a distinct impact before shared cleanup.");
Assert.That(flightRendererHiddenAtImpact, Is.True,
"The flight renderer must be hidden on arrival while its impact still animates.");
Assert.That(hiddenShotsAtImpact.Count, Is.EqualTo(expectedCount),
"Every flight renderer must be hidden on its own arrival.");
Assert.That(damageEvents, Is.EqualTo(expectedCount),
"Every projectile must apply its existing damage on arrival.");
Assert.That(target.Enemy.CurrentHealth, Is.LessThan(healthBefore));
}
private static void AssertStaffTipStart(DefenseTower tower, SpriteRenderer shot)
{
Vector2 travelStart = (Vector2)GetSequenceField(
shot.GetComponent<DefenseSpriteSequence>(), "travelStart");
Assert.That(IsStaffTipAnchor(tower, travelStart), Is.True,
"Each Cold projectile, including delayed shots, starts at a measured staff-tip frame anchor.");
}
private static bool IsStaffTipAnchor(DefenseTower tower, Vector2 position)
{
SpriteRenderer body = (SpriteRenderer)typeof(DefenseTower).GetField(
"bodyRenderer", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(tower);
Sprite sprite = body.sprite;
for (int frame = 0; frame < 6; frame++)
{
Vector2 tipPixels = DefenseTowerArtBounds.StaffTipPixels(true, frame);
Vector2 localPixels = tipPixels - sprite.pivot;
localPixels.y = sprite.rect.height - tipPixels.y - sprite.pivot.y;
if (body.flipX) localPixels.x = -localPixels.x;
Vector2 anchor = body.transform.TransformPoint(
localPixels / sprite.pixelsPerUnit);
if (Vector2.Distance(anchor, position) < 0.0001f) return true;
}
return false;
}
private static Vector2 GetStaffTipWorldPosition(DefenseTower tower) =>
(Vector2)typeof(DefenseTower).GetMethod("GetStaffTipWorldPosition",
BindingFlags.Instance | BindingFlags.NonPublic).Invoke(tower, null);
private static object GetSequenceField(DefenseSpriteSequence sequence, string name) =>
typeof(DefenseSpriteSequence).GetField(name,
BindingFlags.Instance | BindingFlags.NonPublic).GetValue(sequence);
[UnityTest]
public IEnumerator LightningTiersOneToThreeDoNotCreateImpactWaves()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
DefenseRouteAgent target = FindDummy(DefenseTowerKind.Lightning);
foreach (DefenseTower tower in Object.FindObjectsByType<DefenseTower>(FindObjectsSortMode.None))
if (tower != lightning) tower.enabled = false;
Assert.That(target.Enemy.TryReposition(lightning.transform.position), Is.True);
Physics2D.SyncTransforms();
for (int tier = 1; tier <= 3; tier++)
{
if (tier > 1)
{
Assert.That(player.TryReposition(
controller.Tuning.GetBuildPadPosition(lightning.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(
lightning.PadIndex, DefenseTowerBranch.None), Is.True);
}
SetField(lightning, "attackCooldown", 60f);
Assert.That(lightning.TryPulse(), Is.True, $"T{tier} lightning should attack.");
GameObject effect = GameObject.Find("Lightning Attack Effect");
Assert.That(effect, Is.Not.Null);
foreach (SpriteRenderer renderer in effect.GetComponentsInChildren<SpriteRenderer>(true))
Assert.That(renderer.name, Is.Not.EqualTo("Impact Sprite"),
$"T{tier} lightning must not create an impact wave.");
Object.DestroyImmediate(effect);
}
}
[UnityTest]
public IEnumerator TowerVisuals_AlignHitsFitStormShrinkStunAndStaggerBlizzardLanding()
{
yield return StartTowerHitTestRun();
DefensePrototypeController controller = DefensePrototypeController.Instance;
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Assert.That(controller, Is.Not.Null);
Assert.That(player, Is.Not.Null);
DefenseTower[] allTowers = Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None);
foreach (DefenseTower tower in allTowers) tower.enabled = false;
// Fire impact uses the enemy body position and target-relative sorting.
DefenseTower fire = FindTower(DefenseTowerKind.Direct);
DefenseRouteAgent fireTarget = FindDummy(DefenseTowerKind.Direct);
fire.enabled = true;
SetField(fire, "attackCooldown", 60f);
float fireHealth = fireTarget.Enemy.CurrentHealth;
Assert.That(fire.TryPulse(), Is.True);
float fireDeadline = Time.realtimeSinceStartup + 1f;
while (Time.realtimeSinceStartup < fireDeadline
&& fireTarget.Enemy.CurrentHealth >= fireHealth)
yield return null;
Assert.That(fireTarget.Enemy.CurrentHealth, Is.LessThan(fireHealth));
GameObject fireEffect = GameObject.Find("Direct Attack Effect");
Assert.That(fireEffect, Is.Not.Null);
Transform fireImpact = fireEffect.transform.Find("Impact Sprite");
Assert.That(fireImpact, Is.Not.Null);
SpriteRenderer fireRenderer = fireImpact.GetComponent<SpriteRenderer>();
SpriteRenderer fireTargetRenderer = fireTarget.Enemy.GetComponent<SpriteRenderer>();
Vector2 expectedFireCenter = fireTarget.Enemy.transform.position;
Assert.That(Vector2.Distance(fireImpact.position, expectedFireCenter),
Is.LessThan(0.01f));
AssertHitSorting(fireRenderer, fireTargetRenderer);
GameObject sharedFireImpact = GameObject.Find("Tower Impact Direct");
Assert.That(sharedFireImpact, Is.Not.Null);
AssertHitSorting(sharedFireImpact.GetComponent<SpriteRenderer>(), fireTargetRenderer);
CaptureRender("tower-visual-01-fire-impact.png");
// LightningStorm's measured ground center and furthest ring pixel fit radius 2.4.
DefenseTower lightning = FindTower(DefenseTowerKind.Lightning);
UpgradeToSecondBranch(controller, player, lightning);
lightning.enabled = true;
SetField(lightning, "attackCooldown", 60f);
Assert.That(DefenseTower.GetBranchName(
DefenseTowerKind.Lightning, DefenseTowerBranch.Second),
Is.EqualTo("LIGHTNING STORM"));
Assert.That(lightning.TryPulse(), Is.True);
CaptureRender("tower-visual-02-lightning-storm.png");
GameObject lightningEffect = GameObject.Find("Lightning Attack Effect");
Assert.That(lightningEffect, Is.Not.Null);
Transform storm = lightningEffect.transform.Find("Shot Sprite");
Assert.That(storm, Is.Not.Null);
DefenseSpriteSequence stormSequence =
storm.GetComponent<DefenseSpriteSequence>();
SpriteRenderer stormRenderer = storm.GetComponent<SpriteRenderer>();
Vector2 stormCenter = FindDummy(DefenseTowerKind.Lightning).transform.position;
AssertHitSorting(stormRenderer,
FindDummy(DefenseTowerKind.Lightning).Enemy.GetComponent<SpriteRenderer>());
const string stormKey = "Effects/Lightning/LightningStorm";
Sprite[] stormSprites = DefenseTowerArt.Load(stormKey);
Assert.That(stormSprites, Has.Length.EqualTo(16));
GameObject boundsProbe = new("Lightning Storm Bounds Probe");
SpriteRenderer probeRenderer = boundsProbe.AddComponent<SpriteRenderer>();
DefenseSpriteSequence probeSequence =
boundsProbe.AddComponent<DefenseSpriteSequence>();
probeSequence.Play(probeRenderer, stormKey, stormSprites, false);
for (int i = 0; i < stormSprites.Length; i++)
{
Sprite frame = stormSprites[i];
probeRenderer.sprite = frame;
probeSequence.FitLightningStormGround(stormCenter, 2.4f);
Vector2 localCenter =
(DefenseTowerArtBounds.LightningStormGroundCenterPixels - frame.pivot)
/ frame.pixelsPerUnit;
Assert.That(Vector2.Distance(
probeRenderer.transform.TransformPoint(localCenter), stormCenter),
Is.LessThan(0.01f), $"Ground center frame {i}.");
float fittedRadius = probeRenderer.transform.lossyScale.x
* DefenseTowerArtBounds.LightningStormGroundRadiusPixels
/ frame.pixelsPerUnit;
Assert.That(fittedRadius, Is.LessThanOrEqualTo(2.4001f),
$"Measured ground footprint frame {i}.");
}
Object.Destroy(boundsProbe);
float ringFrameDeadline = Time.realtimeSinceStartup + 1f;
int visibleStormFrame = FindFrameIndex(stormSprites, stormRenderer.sprite);
while ((visibleStormFrame < 4 || visibleStormFrame > 10)
&& Time.realtimeSinceStartup < ringFrameDeadline)
{
yield return null;
visibleStormFrame = stormRenderer != null
? FindFrameIndex(stormSprites, stormRenderer.sprite) : -1;
}
Assert.That(visibleStormFrame, Is.InRange(4, 10),
"The second Storm capture must include visible ground-ring frames.");
CaptureRender("tower-visual-02b-lightning-groundring.png");
// Seismic shows a small shadow-centered marker while keeping tower range guidance.
DefenseTower seismic = FindTower(DefenseTowerKind.Seismic);
DefenseRouteAgent seismicTarget = FindDummy(DefenseTowerKind.Seismic);
seismic.enabled = true;
SetField(seismic, "attackCooldown", 60f);
Assert.That(seismic.TryPulse(), Is.True);
GameObject seismicEffect = GameObject.Find("Seismic Attack Effect");
Assert.That(seismicEffect, Is.Not.Null);
SpriteRenderer stunArt = seismicEffect.transform.Find("Impact Sprite")
.GetComponent<SpriteRenderer>();
SpriteRenderer seismicTargetRenderer = seismicTarget.Enemy.GetComponent<SpriteRenderer>();
const int seismicMaximumVisiblePixels = 32;
float expectedMarkerScale = DefenseTowerArtBounds.SeismicGroundMarkerWidthPixels
* Mathf.Abs(seismicTargetRenderer.transform.lossyScale.x)
* stunArt.sprite.pixelsPerUnit
/ (seismicTargetRenderer.sprite.pixelsPerUnit
* seismicMaximumVisiblePixels
* Mathf.Abs(seismicEffect.transform.lossyScale.x));
Assert.That(stunArt.transform.localScale.x,
Is.EqualTo(expectedMarkerScale).Within(0.01f),
"The constant effect scale must fit the measured maximum visible frame width.");
Assert.That((Vector2)stunArt.transform.position,
Is.EqualTo(DefenseTowerArtBounds.EnemyShadowWorldPosition(
seismicTarget.Enemy, seismicTarget.transform.position)));
Assert.That(stunArt.sortingLayerID, Is.EqualTo(seismicTargetRenderer.sortingLayerID));
Assert.That(stunArt.sortingOrder, Is.EqualTo(seismicTargetRenderer.sortingOrder - 1));
Assert.That(seismicEffect.transform.Find("Impact Radius"), Is.Null,
"The large transient Seismic radius ring is omitted from the ground marker.");
LineRenderer seismicRange = seismic.transform.Find("Effect Range")
.GetComponent<LineRenderer>();
Assert.That(seismicRange, Is.Not.Null,
"The tower's persistent attack-range display remains available.");
Assert.That(seismicRange.GetPosition(0).magnitude, Is.GreaterThan(0f));
CaptureRender("tower-visual-03-seismic-stun.png");
// Blizzard staggers shards and applies damage at each landing ellipse.
DefenseTower cold = FindTower(DefenseTowerKind.Cold);
SetField(cold, "attackCooldown", 60f);
cold.enabled = false;
yield return new WaitForSeconds(1f);
UpgradeToSecondBranch(controller, player, cold);
cold.enabled = true;
SetField(cold, "attackCooldown", 60f);
DefenseRouteAgent coldTarget = FindDummy(DefenseTowerKind.Cold);
float coldHealth = coldTarget.Enemy.CurrentHealth;
float blizzardStartedAt = Time.time;
Assert.That(DefenseTower.GetBranchName(
DefenseTowerKind.Cold, DefenseTowerBranch.Second),
Is.EqualTo("BLIZZARD"));
Assert.That(cold.TryPulse(), Is.True);
GameObject coldEffect = GameObject.Find("Cold Attack Effect");
Assert.That(coldEffect, Is.Not.Null);
Vector2 coldShadowCenter = DefenseTowerArtBounds.EnemyShadowWorldPosition(
coldTarget.Enemy, coldTarget.transform.position);
Assert.That(Vector2.Distance(coldEffect.transform.position,
coldShadowCenter), Is.LessThan(0.01f));
LineRenderer radiusLine = coldEffect.transform.Find("Impact Radius")
.GetComponent<LineRenderer>();
Assert.That(radiusLine, Is.Not.Null);
Assert.That(radiusLine.startColor.a,
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactRadiusAlpha).Within(0.001f),
"Only Blizzard softens the per-cast radius outline.");
Assert.That(cold.transform.Find("Effect Range").GetComponent<LineRenderer>()
.startColor.a, Is.EqualTo(DefenseTowerArtBounds.BlizzardRangeAlpha).Within(0.001f),
"Only Blizzard uses the quieter live range outline.");
float radius = 0f;
for (int i = 0; i < radiusLine.positionCount; i++)
radius = Mathf.Max(radius, radiusLine.GetPosition(i).magnitude);
Assert.That(radius, Is.EqualTo(1.65f).Within(0.01f));
Assert.That(coldTarget.Enemy.CurrentHealth, Is.EqualTo(coldHealth),
"Blizzard damage waits for the first shard landing.");
SpriteRenderer[] blizzardSprites =
coldEffect.GetComponentsInChildren<SpriteRenderer>(true);
SpriteRenderer[] shards = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] trails = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] impacts = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
SpriteRenderer[] waves = new SpriteRenderer[DefenseTowerArtBounds.BlizzardShardCount];
int shardCount = 0;
int trailCount = 0;
int impactCount = 0;
int waveCount = 0;
foreach (SpriteRenderer renderer in blizzardSprites)
{
if (renderer.name.StartsWith("Blizzard Shard "))
{
int index = renderer.name["Blizzard Shard ".Length] - '0';
shards[index] = renderer;
shardCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardShardScale).Within(0.001f));
Assert.That(renderer.sprite,
Is.SameAs(DefenseTowerArt.Load("Effects/Ice/Shard-Blizzard-v1")[0]));
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
"Delayed shards stay hidden until their staggered launch.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
else if (renderer.name.StartsWith("Blizzard Trail "))
{
int index = renderer.name["Blizzard Trail ".Length] - '0';
trails[index] = renderer;
trailCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailScale).Within(0.001f));
Assert.That(renderer.color.a,
Is.EqualTo(DefenseTowerArtBounds.BlizzardTrailAlpha).Within(0.001f));
Assert.That(renderer.enabled, Is.EqualTo(index == 0),
"The brief shard trail follows its staggered projectile.");
}
else if (renderer.name.StartsWith("Blizzard Impact "))
{
int index = renderer.name["Blizzard Impact ".Length] - '0';
impacts[index] = renderer;
impactCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardImpactScale).Within(0.001f));
Assert.That(renderer.enabled, Is.False,
"Each impact stays hidden until its shard lands.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
else if (renderer.name.StartsWith("Blizzard Wave "))
{
int index = renderer.name["Blizzard Wave ".Length] - '0';
waves[index] = renderer;
waveCount++;
Assert.That(renderer.transform.localScale.x,
Is.EqualTo(DefenseTowerArtBounds.BlizzardWaveScale).Within(0.001f));
Assert.That(renderer.enabled, Is.False,
"Each ground wave waits for its matching shard landing.");
Vector2 expectedCenter = (Vector2)coldEffect.transform.position
+ DefenseTowerArtBounds.BlizzardLandingOffset(index, radius);
Vector2 localCenter = (DefenseTowerArtBounds.ImpactWaveCenterPixels(
0, renderer.sprite) - renderer.sprite.pivot)
/ renderer.sprite.pixelsPerUnit;
Assert.That(Vector2.Distance(
renderer.transform.TransformPoint(localCenter), expectedCenter),
Is.LessThan(0.01f), $"Wave {index} stays centered on its landing.");
float outerRadius = 0f;
foreach (Vector2 vertex in renderer.sprite.vertices)
{
outerRadius = Mathf.Max(outerRadius, Vector2.Distance(
renderer.transform.TransformPoint(vertex), expectedCenter));
}
Assert.That(Vector2.Distance(expectedCenter, coldEffect.transform.position)
+ outerRadius, Is.LessThanOrEqualTo(radius + 0.01f),
$"Wave {index} stays within the Blizzard attack radius.");
AssertHitSorting(renderer,
coldTarget.Enemy.GetComponent<SpriteRenderer>());
}
}
Assert.That(shardCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(trailCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(impactCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
Assert.That(waveCount, Is.EqualTo(DefenseTowerArtBounds.BlizzardShardCount));
float firstLandingDeadline = Time.realtimeSinceStartup + 0.6f;
while (!impacts[0].enabled && Time.realtimeSinceStartup < firstLandingDeadline)
yield return null;
Assert.That(impacts[0].enabled, Is.True,
"The first impact appears when the first shard lands.");
Assert.That(waves[0].enabled, Is.True,
"The first ground wave appears when the first shard lands.");
Assert.That(Time.time - blizzardStartedAt,
Is.EqualTo(DefenseTowerArtBounds.BlizzardLandingSeconds).Within(0.05f));
yield return new WaitForSeconds(0.21f);
float landingDeadline = Time.realtimeSinceStartup + 0.25f;
while (Time.realtimeSinceStartup < landingDeadline
&& !impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled)
yield return null;
Assert.That(impacts[DefenseTowerArtBounds.BlizzardShardCount - 1].enabled, Is.True,
"The final wave lands at its staggered time.");
Assert.That(Time.time - blizzardStartedAt,
Is.EqualTo(DefenseTowerArtBounds.BlizzardLastLandingSeconds).Within(0.05f));
int lastImpact = DefenseTowerArtBounds.BlizzardShardCount - 1;
yield return null;
Assert.That(impacts[lastImpact].enabled, Is.True,
"The final impact appears on the frame after its shard lands.");
Assert.That(waves[lastImpact].enabled, Is.True,
"The final wave appears on the frame after its shard lands.");
float captureTimeScale = Time.timeScale;
Time.timeScale = 0f;
CaptureRender("tower-visual-04-blizzard-last-landing.png");
Time.timeScale = captureTimeScale;
yield return new WaitForSeconds(0.2f);
Assert.That(coldEffect, Is.Not.Null,
"The attack root remains alive while the final wave animation is playing.");
float cleanupDeadline = Time.realtimeSinceStartup + 0.6f;
while (coldEffect != null && Time.realtimeSinceStartup < cleanupDeadline)
yield return null;
Assert.That(coldEffect == null, Is.True,
"The attack root is destroyed after every shard, impact, trail, and wave sequence finishes.");
}
private void CaptureRender(string fileName)
{
if (!cameraStateSaved)
{
captureCameraFollow = Object.FindAnyObjectByType<ArenaCameraFollow>();
captureCamera = captureCameraFollow != null
? captureCameraFollow.WorldCamera : Camera.main;
Assert.That(captureCamera, Is.Not.Null);
originalCameraPosition = captureCamera.transform.position;
originalCameraOrthographicSize = captureCamera.orthographicSize;
originalCameraFollowEnabled = captureCameraFollow != null
&& captureCameraFollow.enabled;
cameraStateSaved = true;
}
if (captureCameraFollow != null) captureCameraFollow.enabled = false;
captureCamera.transform.position = new Vector3(
0f, 0f, captureCamera.transform.position.z);
captureCamera.orthographicSize = 9f;
const int width = 1920;
const int height = 1080;
RenderTexture renderTarget = new(width, height, 24);
Assert.That(renderTarget.Create(), Is.True);
Texture2D image = new(width, height, TextureFormat.RGB24, false);
RenderTexture previousTarget = captureCamera.targetTexture;
RenderTexture previousActive = RenderTexture.active;
try
{
captureCamera.targetTexture = renderTarget;
captureCamera.Render();
RenderTexture.active = renderTarget;
image.ReadPixels(new Rect(0, 0, width, height), 0, 0);
image.Apply();
Directory.CreateDirectory(
@"D:\project\game\output\tower-hit-test-v1");
File.WriteAllBytes(
Path.Combine(@"D:\project\game\output\tower-hit-test-v1", fileName),
image.EncodeToPNG());
}
finally
{
captureCamera.targetTexture = previousTarget;
RenderTexture.active = previousActive;
Object.Destroy(image);
renderTarget.Release();
Object.Destroy(renderTarget);
}
}
private static int FindFrameIndex(Sprite[] frames, Sprite current)
{
for (int i = 0; i < frames.Length; i++)
if (frames[i] == current) return i;
return -1;
}
private static void AssertHitSorting(Renderer effect, SpriteRenderer target)
{
Assert.That(effect.sortingLayerID, Is.EqualTo(target.sortingLayerID));
Assert.That(effect.sortingOrder,
Is.EqualTo(target.sortingOrder + DefenseTowerArtBounds.TowerHitSortingOffset));
}
private static void UpgradeToSecondBranch(
DefensePrototypeController controller, PlayerController player,
DefenseTower tower)
{
Assert.That(player.TryReposition(
controller.Tuning.GetBuildPadPosition(tower.PadIndex)), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.None), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.None), Is.True);
Assert.That(controller.TryUpgradeTowerAtPad(tower.PadIndex,
DefenseTowerBranch.Second), Is.True);
}
private static DefenseTower FindTower(DefenseTowerKind kind)
{
int padIndex = System.Array.IndexOf(
DefenseTowerHitTestLayout.KindsByPad, kind);
Assert.That(padIndex, Is.GreaterThanOrEqualTo(0),
$"Tower kind {kind} is absent from the hit-test layout.");
DefenseTower[] towers = Object.FindObjectsByType<DefenseTower>(
FindObjectsSortMode.None);
foreach (DefenseTower tower in towers)
if (tower.PadIndex == padIndex && tower.Kind == kind) return tower;
Assert.Fail($"Missing tower {kind} at pad {padIndex}.");
return null;
}
private static DefenseRouteAgent FindDummy(DefenseTowerKind kind)
{
DefenseRouteAgent[] agents = Object.FindObjectsByType<DefenseRouteAgent>(
FindObjectsSortMode.None);
string expectedName = $"Tower Test Dummy {kind}";
foreach (DefenseRouteAgent agent in agents)
if (agent.Enemy != null && agent.Enemy.name == expectedName)
return agent;
Assert.Fail($"Missing fixed target {expectedName}.");
return null;
}
private static void SetField(object instance, string name, object value)
{
FieldInfo field = instance.GetType().GetField(
name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null, $"Missing field {name}.");
field.SetValue(instance, value);
}
private static IEnumerator StartTowerHitTestRun()
{
AsyncOperation load = SceneManager.LoadSceneAsync(SceneName);
while (!load.isDone) yield return null;
yield return null;
yield return null;
RunManager runManager = RunManager.Instance;
Assert.That(runManager, Is.Not.Null);
DefensePrototypeController.PrepareTowerHitTestRun();
Assert.That(runManager.BeginRun(RunMode.DefensePrototype), Is.True);
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline
&& (DefensePrototypeController.Instance == null
|| !DefensePrototypeController.Instance.IsActive))
yield return null;
Assert.That(DefensePrototypeController.Instance, Is.Not.Null);
Assert.That(DefensePrototypeController.Instance.IsActive, Is.True);
yield return CloseStartupSelection(runManager);
}
private static IEnumerator CloseStartupSelection(RunManager runManager)
{
float deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline && !runManager.IsSelectionOpen)
yield return null;
Assert.That(runManager.IsSelectionOpen, Is.True);
#if UNITY_EDITOR
ArtifactRewardController rewardController =
Object.FindAnyObjectByType<ArtifactRewardController>();
Assert.That(rewardController, Is.Not.Null);
int count = runManager.StartupArtifactSelectionCount;
Assert.That(count, Is.GreaterThan(0));
for (int i = 0; i < count; i++)
{
Assert.That(rewardController.DebugChooseOption(0), Is.True);
yield return null;
}
#else
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
#endif
yield return null;
#if UNITY_EDITOR
RunTutorialController tutorial =
Object.FindAnyObjectByType<RunTutorialController>();
Assert.That(tutorial, Is.Not.Null);
bool observed = false;
deadline = Time.realtimeSinceStartup + ReadyTimeoutSeconds;
while (Time.realtimeSinceStartup < deadline)
{
if (tutorial.DebugIsVisible)
{
observed = true;
Assert.That(tutorial.DebugContinue(), Is.True);
yield return null;
continue;
}
if (!runManager.IsSelectionOpen && tutorial.DebugPendingCount == 0)
break;
yield return null;
}
Assert.That(observed, Is.True);
Assert.That(tutorial.DebugIsVisible, Is.False);
#else
Assert.Ignore("The tower visual fixture requires Unity Editor test APIs.");
#endif
Assert.That(runManager.IsSelectionOpen, Is.False);
}
}
}