Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DRunHudPlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

998 lines
50 KiB
C#

using System.Collections;
using System.Collections.Generic;
using System;
using System.Reflection;
using BumpCombat.Core;
using BumpCombat.Defense;
using BumpCombat.Constants;
using BumpCombat.Defense.Comparison;
using BumpCombat.Enemies;
using BumpCombat.Progression;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
using UnityEngine.TestTools;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace BumpCombat.Tests
{
public sealed class Defense25DRunHudPlayModeTests
{
private const string ConstantsPath =
"Assets/_Project/Constants/Defense25DComparison/RunHudConstants.asset";
private GameObject hudTestWorldObject;
private GameObject hudTestObject;
private Defense25DComparisonSettings hudTestSettings;
private Defense25DRunDefinition hudTestRunDefinition;
private Defense25DWaveDirectorConstants hudTestWaveConstants;
private Defense25DProgression hudTestProgression;
private Defense25DRewardConstants hudTestRewardConstants;
private ModifierDefinition hudTestModifier;
private float previousTimeScale;
private InputSettings.BackgroundBehavior previousBackgroundBehavior;
private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior;
private bool previousRunInBackground;
private bool hudInputSettingsCaptured;
[SetUp]
public void CaptureTimeScale()
{
previousTimeScale = Time.timeScale;
}
[Test]
public void RunHudConstantsAssetMatchesTheFhdLayoutContract()
{
Defense25DRunHudConstants constants =
AssetDatabase.LoadAssetAtPath<Defense25DRunHudConstants>(ConstantsPath);
Assert.That(constants, Is.Not.Null);
Assert.That(constants.CombatHudBottomLeft, Is.EqualTo(new Vector2(24f, 24f)));
Assert.That(constants.CombatHudSize, Is.EqualTo(new Vector2(392f, 136f)));
Assert.That(constants.HealthTrackSize, Is.EqualTo(new Vector2(352f, 12f)));
Assert.That(constants.ArtifactGaugeSize, Is.EqualTo(new Vector2(352f, 18f)));
Assert.That(constants.ExperienceTrackSize, Is.EqualTo(new Vector2(352f, 8f)));
Assert.That(constants.GuardTrackSize, Is.EqualTo(new Vector2(18f, 94f)));
Assert.That(constants.TimerSize, Is.EqualTo(new Vector2(220f, 66f)));
Assert.That(constants.WaveSummarySize, Is.EqualTo(new Vector2(700f, 116f)));
Assert.That(constants.MinimapSize, Is.EqualTo(new Vector2(300f, 162f)));
Assert.That(constants.PortalListSize, Is.EqualTo(new Vector2(320f, 300f)));
Assert.That(constants.PortalCount, Is.EqualTo(12));
Assert.That(constants.MissionCount, Is.EqualTo(3));
Assert.That(constants.ModalMaximumSize, Is.EqualTo(new Vector2(1040f, 420f)));
}
[TestCase(1280f, 720f, 2f / 3f)]
[TestCase(1920f, 1080f, 1f)]
[TestCase(2560f, 1440f, 4f / 3f)]
[TestCase(1399f, 799f, 1399f / 1920f)]
public void ViewportScaleUsesTheSmallerReferenceAxis(
float width, float height, float expectedScale)
{
float scale = Defense25DRunHudConstants.ScaleForViewport(
new Rect(0f, 0f, width, height));
Assert.That(scale, Is.EqualTo(expectedScale).Within(.0001f));
}
[TestCase(0f, 1080f)]
[TestCase(1920f, 0f)]
[TestCase(-1f, 720f)]
public void InvalidViewportHasNoLayoutScale(float width, float height)
{
Assert.That(Defense25DRunHudConstants.IsValidViewport(
new Rect(0f, 0f, width, height)), Is.False);
Assert.That(Defense25DRunHudConstants.ScaleForViewport(
new Rect(0f, 0f, width, height)), Is.Zero);
}
[UnityTest]
public IEnumerator BindCreatesRealButtonHitRectsAndUnbindClearsThem()
{
GameObject host = new("Run HUD PlayMode Test");
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
hud.Bind(null, null, null);
yield return null;
Assert.That(hud.IsLayoutValid, Is.True);
Rect[] firstRects = hud.GetPointerBlockRects();
Assert.That(firstRects, Is.Not.Empty);
foreach (Rect hitRect in firstRects)
Assert.That(hitRect.width * hitRect.height, Is.GreaterThan(0f));
Graphic[] graphics = host.GetComponentsInChildren<Graphic>(true);
foreach (Graphic graphic in graphics)
if (graphic.raycastTarget)
Assert.That(graphic.GetComponent<Button>(), Is.Not.Null,
$"Decorative graphic '{graphic.name}' must not intercept UI raycasts.");
hud.Bind(null, null, null);
yield return null;
Assert.That(hud.GetPointerBlockRects(), Has.Length.EqualTo(firstRects.Length));
hud.Unbind();
Assert.That(hud.IsLayoutValid, Is.False);
Assert.That(hud.GetPointerBlockRects(), Is.Empty);
Object.Destroy(host);
yield return null;
}
[UnityTest]
public IEnumerator InvalidViewportHidesHudAndValidViewportRestoresIt()
{
GameObject host = new("Run HUD Viewport Test");
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
hud.Bind(null, null, null);
hud.RefreshLayout(new Rect(0f, 0f, 0f, 1080f));
Assert.That(hud.IsLayoutValid, Is.False);
Assert.That(hud.GetPointerBlockRects(), Is.Empty);
hud.RefreshLayout(new Rect(0f, 0f, 1920f, 1080f));
yield return null;
Assert.That(hud.IsLayoutValid, Is.True);
Assert.That(hud.GetPointerBlockRects(), Is.Not.Empty);
hud.Unbind();
Object.Destroy(host);
yield return null;
}
[TestCase(1280, 720, 0f, 0f, 1280f, 720f)]
[TestCase(1920, 1080, 0f, 0f, 1920f, 1080f)]
[TestCase(2560, 1440, 0f, 0f, 2560f, 1440f)]
[TestCase(1399, 799, 59.5f, 39.5f, 1280f, 720f)]
public void ViewportKeepsWorldRenderScaleAndLetterboxBounds(
int screenWidth, int screenHeight,
float x, float y, float width, float height)
{
Rect viewport = Defense25DView.GetViewport(screenWidth, screenHeight);
Assert.That(viewport.x, Is.EqualTo(x).Within(.001f));
Assert.That(viewport.y, Is.EqualTo(y).Within(.001f));
Assert.That(viewport.width, Is.EqualTo(width).Within(.001f));
Assert.That(viewport.height, Is.EqualTo(height).Within(.001f));
}
[TestCase(1280, 720, 2f / 3f)]
[TestCase(1920, 1080, 1f)]
[TestCase(2560, 1440, 4f / 3f)]
[TestCase(1399, 799, 2f / 3f)]
public void HudLayoutUsesViewportScaleAtFhdQhdAndLetterboxedSizes(
int screenWidth, int screenHeight, float expectedScale)
{
GameObject host = new("Run HUD viewport layout test");
try
{
Defense25DRunHud hud = host.AddComponent<Defense25DRunHud>();
hud.Bind(null, null, null);
Rect viewport = Defense25DView.GetViewport(screenWidth, screenHeight);
hud.RefreshLayout(viewport);
CanvasScaler scaler = hud.HudCanvas.GetComponent<CanvasScaler>();
Assert.That(scaler.scaleFactor, Is.EqualTo(expectedScale).Within(.001f));
Vector2 expectedSize = viewport.size / expectedScale;
Assert.That(hud.ViewportRoot.sizeDelta.x, Is.EqualTo(expectedSize.x).Within(.001f));
Assert.That(hud.ViewportRoot.sizeDelta.y, Is.EqualTo(expectedSize.y).Within(.001f));
Assert.That(hud.GetPointerBlockRects(), Is.Not.Empty);
hud.Unbind();
}
finally
{
Object.DestroyImmediate(host);
}
}
[Test]
public void ViewportGroundFootprintUsesCameraRaysWithoutRenderTexture()
{
GameObject cameraObject = new("Viewport Footprint Math Camera");
try
{
Camera camera = cameraObject.AddComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 6f;
camera.transform.SetPositionAndRotation(new Vector3(0f, 30f, -20f),
Quaternion.LookRotation(new Vector3(0f, -.8f, .6f), Vector3.up));
Assert.That(camera.targetTexture, Is.Null);
Assert.That(Defense25DView.TryGetViewportGroundFootprint(camera, 0f,
Vector2.zero, out Vector2 minimum, out Vector2 maximum), Is.True);
Assert.That(minimum.x, Is.LessThan(maximum.x));
Assert.That(minimum.y, Is.LessThan(maximum.y));
Assert.That(float.IsInfinity(minimum.x) || float.IsNaN(minimum.x), Is.False);
}
finally
{
Object.DestroyImmediate(cameraObject);
}
}
[Test]
public void FollowCenterClampsTheTargetToTheGroundViewportFootprint()
{
Vector2 center = Defense25DView.ClampViewportCenter(
new Vector2(100f, -100f), Vector2.zero, new Vector2(20f, 20f),
new Vector2(-5f, -8f), new Vector2(7f, 10f));
Assert.That(center, Is.EqualTo(new Vector2(13f, -12f)));
}
[Test]
public void SmallArenaCentersViewportWhenFootprintIsLargerThanBounds()
{
Vector2 center = Defense25DView.ClampViewportCenter(
new Vector2(80f, -60f), new Vector2(3f, -4f), new Vector2(2f, 1f),
new Vector2(-6f, -5f), new Vector2(8f, 7f));
Assert.That(center, Is.EqualTo(new Vector2(2f, -5f)));
}
[UnityTest]
public IEnumerator TierThreeUpgradeRequiresAnExplicitBranchButtonChoice()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DActor player = hudTestWorldObject.GetComponent<Defense25DComparison>().Player;
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
Vector2 firstPad = world.TowerManager.GetPadPosition(0);
Vector2 secondPad = world.TowerManager.GetPadPosition(1);
player.ResetAt(firstPad);
world.TowerManager.AddCurrency(5000);
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
Assert.That(world.SelectBuildPad(0), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
hudTestHud.RefreshState();
Defense25DTower firstTower = world.TowerManager.GetTowerAtPad(0);
Assert.That(firstTower.Tier, Is.EqualTo(3));
FindButton("Upgrade Selected Tower").onClick.Invoke();
Assert.That(firstTower.Tier, Is.EqualTo(3),
"An unselected T3 branch must not silently upgrade into First.");
Button first = FindButton("Upgrade Branch First");
Button second = FindButton("Upgrade Branch Second");
Assert.That(first, Is.Not.Null);
Assert.That(second, Is.Not.Null);
Assert.That(GetButtonLabel(first), Does.Contain(
DefenseTower.GetBranchName(firstTower.Kind, DefenseTowerBranch.First)));
Assert.That(GetButtonLabel(first), Does.Not.Contain("First"));
Assert.That(GetButtonLabel(second), Does.Contain(
DefenseTower.GetBranchName(firstTower.Kind, DefenseTowerBranch.Second)));
Assert.That(GetButtonLabel(second), Does.Not.Contain("Second"));
first.onClick.Invoke();
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.First));
player.ResetAt(secondPad);
Assert.That(world.SelectBuildPad(1), Is.True);
Assert.That(world.TowerManager.TryBuildTowerAtPad(1, DefenseTowerKind.Direct), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
hudTestHud.RefreshState();
Defense25DTower secondTower = world.TowerManager.GetTowerAtPad(1);
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.None),
"A branch chosen for another pad must be discarded when selection changes.");
FindButton("Upgrade Selected Tower").onClick.Invoke();
Assert.That(secondTower.Tier, Is.EqualTo(3),
"The newly selected T3 tower must wait for its own branch choice.");
FindButton("Upgrade Branch Second").onClick.Invoke();
FindButton("Upgrade Selected Tower").onClick.Invoke();
Assert.That(secondTower.Tier, Is.EqualTo(4));
Assert.That(secondTower.Branch, Is.EqualTo(DefenseTowerBranch.Second));
player.ResetAt(firstPad);
Assert.That(world.SelectBuildPad(0), Is.True);
hudTestHud.RefreshState();
FindButton("Upgrade Branch First").onClick.Invoke();
FindButton("Upgrade Selected Tower").onClick.Invoke();
Assert.That(firstTower.Tier, Is.EqualTo(4));
Assert.That(firstTower.Branch, Is.EqualTo(DefenseTowerBranch.First));
yield return null;
}
[UnityTest]
public IEnumerator TierThreeBranchKeysUseTowerNamesWithoutUpgradingImmediately()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(world.PreparationRemaining + .1f);
Defense25DActor player = world.Player;
player.ResetAt(world.TowerManager.GetPadPosition(0));
world.TowerManager.AddCurrency(5000);
Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True);
Assert.That(world.SelectBuildPad(0), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
Assert.That(world.UpgradeSelectedTower(DefenseTowerBranch.None), Is.True);
hudTestHud.RefreshState();
Defense25DTower tower = world.TowerManager.GetTowerAtPad(0);
Button first = FindButton("Upgrade Branch First");
Button second = FindButton("Upgrade Branch Second");
Assert.That(GetButtonLabel(first), Does.Contain(
DefenseTower.GetBranchName(tower.Kind, DefenseTowerBranch.First)));
Assert.That(GetButtonLabel(second), Does.Contain(
DefenseTower.GetBranchName(tower.Kind, DefenseTowerBranch.Second)));
Assert.That(GetButtonLabel(first), Does.Not.Contain("First"));
Assert.That(GetButtonLabel(second), Does.Not.Contain("Second"));
BeginHudKeyboardInput();
yield return PressHudKey(Key.J);
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.First));
Assert.That(tower.Tier, Is.EqualTo(3), "J selects a branch; it does not upgrade.");
yield return ReleaseHudKeys();
yield return PressHudKey(Key.K);
Assert.That(hudTestHud.SelectedUpgradeBranch, Is.EqualTo(DefenseTowerBranch.Second));
Assert.That(tower.Tier, Is.EqualTo(3), "K selects a branch; it does not upgrade.");
FindButton("Upgrade Selected Tower").onClick.Invoke();
Assert.That(tower.Tier, Is.EqualTo(4));
Assert.That(tower.Branch, Is.EqualTo(DefenseTowerBranch.Second));
yield return ReleaseHudKeys();
}
[UnityTest]
public IEnumerator FieldRelicModalUsesTheExistingReadableRelicLabel()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
FieldInfo choicesField = typeof(Defense25DComparison).GetField(
"earnedRelicChoices", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(choicesField, Is.Not.Null);
var choices = choicesField.GetValue(world) as Queue<PassiveRelicId[]>;
Assert.That(choices, Is.Not.Null);
choices.Enqueue(new[] { PassiveRelicId.TwinHornTalisman });
hudTestHud.RefreshState();
string label = GetButtonLabel(FindButton("Choice 1"));
Assert.That(label, Does.Contain("TWIN HORN TALISMAN"));
Assert.That(label, Does.Contain("ordinary bump deals 2x damage"));
Assert.That(label, Does.Not.Contain(nameof(PassiveRelicId.TwinHornTalisman)));
yield return null;
}
[UnityTest]
public IEnumerator HudPauseKeysRespectPendingChoiceAndRNeverRestartsFromHud()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(world.PreparationRemaining + .1f);
world.ReadInput = true;
BeginHudKeyboardInput();
int generation = world.RunGeneration;
yield return PressHudKey(Key.R);
Assert.That(world.RunGeneration, Is.EqualTo(generation),
"The HUD must not bind R to an immediate run reset.");
yield return ReleaseHudKeys();
yield return PressHudKey(Key.P);
Assert.That(world.Paused, Is.True);
yield return ReleaseHudKeys();
yield return PressHudKey(Key.Escape);
Assert.That(world.Paused, Is.False);
yield return ReleaseHudKeys();
hudTestRewardConstants = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset"));
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
hudTestModifier.Configure("hud.modal-priority", "Modal Priority",
CharacterStat.MoveSpeed, ModifierOperation.Increased, .1f, 1);
hudTestProgression = new Defense25DProgression();
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
Assert.That(hudTestProgression.QueueReward(Defense25DRewardSource.LevelUp,
"hud-modal-priority"), Is.True);
hudTestHud.Bind(world, hudTestProgression, null);
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
yield return PressHudKey(Key.P);
Assert.That(world.Paused, Is.False, "A pending choice takes priority over pause input.");
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
yield return ReleaseHudKeys();
yield return PressHudKey(Key.Escape);
Assert.That(world.Paused, Is.False, "Escape must not steal input from a choice modal.");
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
yield return ReleaseHudKeys();
yield return PressHudKey(Key.A);
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True,
"Holding A must not resolve or leak through the open choice modal.");
Assert.That(hudTestProgression.CurrentChoices, Is.Not.Empty);
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain("선택하면 즉시 적용"));
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain(
hudTestProgression.CurrentChoices[0].DisplayData.Title));
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain(
hudTestProgression.CurrentChoices[0].DisplayData.Description));
Assert.That(GetButtonLabel(FindButton("Choice 1")), Does.Contain("등급"));
string rarityLabel = hudTestProgression.CurrentChoices[0].Rarity switch
{
Defense25DRewardRarity.Common => "일반",
Defense25DRewardRarity.Rare => "희귀",
Defense25DRewardRarity.Epic => "에픽",
_ => "등급 없음",
};
Assert.That(GetButtonLabel(FindButton("Choice 1")),
Does.Contain($"등급 · {rarityLabel}"));
Assert.That(CountOccurrences(GetButtonLabel(FindButton("Choice 1")),
"선택하면 즉시 적용됩니다"), Is.EqualTo(1),
"A reward description and the HUD must not duplicate the confirmation copy.");
yield return ReleaseHudKeys();
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True,
"Releasing A after a modal is open must not activate a choice.");
Assert.That(hudTestHud.IsGameplayInputBlocked, Is.True,
"The pending choice continues to own gameplay input after A is released.");
FindButton("Choice 1").onClick.Invoke();
Assert.That(hudTestProgression.HasPendingLevelChoice, Is.False,
"Choosing the reward must synchronously resolve its pending level choice.");
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.False,
"The HUD choice modal must refresh when Progression raises Changed.");
Assert.That(hudTestHud.IsGameplayInputBlocked, Is.False,
"Gameplay input is released after the pending choice resolves.");
}
[UnityTest]
public IEnumerator ResultPanelShowsLedgerProgressionAndMainRunSeedAndEndReason()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(world.PreparationRemaining + 0.1f);
hudTestRewardConstants = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset"));
ModifierDefinition catalogModifier = FindFirstLevelUpModifier();
bool hasCatalogModifier = catalogModifier != null;
if (hasCatalogModifier)
hudTestModifier = Object.Instantiate(catalogModifier);
else
{
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
hudTestModifier.Configure("hud.result", "Result modifier",
CharacterStat.MoveSpeed, ModifierOperation.Increased, .1f, 1);
}
hudTestProgression = new Defense25DProgression();
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
hudTestHud.Bind(world, hudTestProgression, null);
Assert.That(hudTestModifier.TryApplyToModel(world.Player, null), Is.True);
SetPrivateResult(world, "RunFailed", true);
typeof(Defense25DComparison).GetField("runEndReason",
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
Defense25DRunEndReason.FinalBossArrived);
hudTestHud.RefreshState();
string details = FindText("Run Result Details").text;
Assert.That(FindButton("Restart Run").gameObject.activeInHierarchy, Is.True);
Assert.That(details, Does.Contain("최종 보스·종료 사유: 최종 보스 도착"));
Assert.That(details, Does.Contain($"공급 {world.Ledger.SuppliedCount}"));
Assert.That(details, Does.Contain($"처치 {world.Ledger.KillEventCount}"));
Assert.That(details, Does.Contain($"시드 {world.CurrentRunSeed}"));
Assert.That(details, Does.Contain("미션 성공 0 · 실패 0"));
Assert.That(details, Does.Contain("최종 레벨 1"));
Assert.That(details, Does.Contain("현재 플레이어 효과:"));
if (hasCatalogModifier)
Assert.That(details, Does.Contain(hudTestModifier.GetKoreanDisplayName()));
else
Assert.That(details, Does.Contain("기타 활성 효과 1종"));
Assert.That(details, Does.Not.Contain(hudTestModifier.ModifierId));
Assert.That(details, Does.Not.Contain(nameof(Defense25DSpawnSource.FinalBoss)));
typeof(Defense25DComparison).GetField("runEndReason",
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
Defense25DRunEndReason.None);
SetPrivateResult(world, "RunCleared", true);
hudTestHud.RefreshState();
Assert.That(FindText("Run Result Details").text,
Does.Contain("최종 보스·종료 사유: 최종 보스 처치"));
SetPrivateResult(world, "RunFailed", true);
typeof(Defense25DComparison).GetField("runEndReason",
BindingFlags.Instance | BindingFlags.NonPublic).SetValue(world,
Defense25DRunEndReason.BaseDestroyed);
hudTestHud.RefreshState();
Assert.That(FindText("Run Result Details").text,
Does.Contain("최종 보스·종료 사유: 거점 파괴"));
yield return null;
}
[UnityTest]
public IEnumerator ActivePausedRestartCanCancelAndConfirmOnlyOnce()
{
yield return InitializeHudTestWorld(longRun: false);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.SetPaused(true);
hudTestHud.RefreshState();
int generation = world.RunGeneration;
Button restart = FindButton("Restart Run");
restart.onClick.Invoke();
restart.onClick.Invoke();
Assert.That(IsRestartConfirmationVisible(), Is.True);
Assert.That(world.RunGeneration, Is.EqualTo(generation));
FindButton("Cancel Restart").onClick.Invoke();
Assert.That(IsRestartConfirmationVisible(), Is.False);
Assert.That(world.RunGeneration, Is.EqualTo(generation));
restart.onClick.Invoke();
Button confirm = FindButton("Confirm Restart");
confirm.onClick.Invoke();
confirm.onClick.Invoke();
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
Assert.That(IsRestartConfirmationVisible(), Is.False);
yield return null;
}
[UnityTest]
public IEnumerator PendingRewardBlocksRestartUntilChoiceIsResolved()
{
yield return InitializeHudTestWorld(longRun: false);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
hudTestRewardConstants = ScriptableObject.CreateInstance<Defense25DRewardConstants>();
hudTestRewardConstants.TowerModifierCatalog = Array.Empty<Defense25DTowerModifierDefinition>();
hudTestModifier = ScriptableObject.CreateInstance<ModifierDefinition>();
hudTestModifier.Configure("hud.pending", "hud.pending", CharacterStat.MoveSpeed,
ModifierOperation.Increased, .1f, 1);
hudTestProgression = new Defense25DProgression();
hudTestProgression.Bind(world.Player, hudTestRewardConstants, new[] { hudTestModifier },
new Defense25DTowerModifiers(hudTestRewardConstants), _ => false);
Assert.That(hudTestProgression.QueueReward(Defense25DRewardSource.LevelUp,
"hud-pending-restart"), Is.True);
hudTestHud.Bind(world, hudTestProgression, null);
world.SetPaused(true);
int generation = world.RunGeneration;
FindButton("Restart Run").onClick.Invoke();
Assert.That(IsRestartConfirmationVisible(), Is.False);
Assert.That(world.RunGeneration, Is.EqualTo(generation));
Assert.That(hudTestProgression.TryChooseModifier(0), Is.True);
hudTestHud.RefreshState();
FindButton("Restart Run").onClick.Invoke();
Assert.That(IsRestartConfirmationVisible(), Is.True);
Assert.That(world.RunGeneration, Is.EqualTo(generation));
yield return null;
}
[UnityTest]
public IEnumerator ConfirmedLongRunRestartRebindsMinimapToTheNewLayout()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(world.PreparationRemaining + 0.1f);
world.SetPaused(true);
Defense25DRunLayout previousLayout = world.RunLayout;
int previousGeneration = world.RunGeneration;
FindButton("Restart Run").onClick.Invoke();
Assert.That(IsRestartConfirmationVisible(), Is.True);
FindButton("Confirm Restart").onClick.Invoke();
Assert.That(world.RunGeneration, Is.EqualTo(previousGeneration + 1));
Assert.That(world.RunLayout, Is.Not.SameAs(previousLayout));
hudTestHud.RefreshState();
FieldInfo cachedLayoutSeed = typeof(Defense25DRunHud).GetField("layoutSeed",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(cachedLayoutSeed.GetValue(hudTestHud),
Is.EqualTo(world.RunLayout.LayoutSeed),
"Restart must rebuild the HUD minimap from the newly generated layout.");
Assert.That(hudTestHud.IsRestartConfirmationPending, Is.False);
yield return null;
}
[UnityTest]
public IEnumerator ArtifactDropCandidateIsShownAndChosenThroughHudModal()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(20f);
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
world.AdvanceRunClock(60f);
Defense25DArtifactDropState drop = world.ArtifactDropStates[0];
world.Player.ResetAt(drop.Position);
Assert.That(world.TryInteractWithArtifactDrop(drop.DropId), Is.True);
Assert.That(world.CurrentArtifactDropCandidates, Has.Count.EqualTo(2));
hudTestHud.RefreshState();
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.True);
Assert.That(FindButton("Choice 2").gameObject.activeInHierarchy, Is.True);
FindButton("Choice 1").onClick.Invoke();
Assert.That(world.HasPendingArtifactDropChoice, Is.False);
Assert.That(world.Loadout.OwnedArtifactCount, Is.EqualTo(1));
Assert.That(FindButton("Choice 1").gameObject.activeInHierarchy, Is.False);
yield return null;
}
[UnityTest]
public IEnumerator PortalRowsSeparateTelegraphOpenAndStoppedUnopenedStates()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(130f);
hudTestHud.RefreshState();
Text thirdPortal = FindText("Portal 3");
Assert.That(thirdPortal.text, Does.Contain("예고 00:10"));
Assert.That(thirdPortal.text, Does.Contain("위협"));
world.AdvanceRunClock(10f);
hudTestHud.RefreshState();
Assert.That(world.WaveDirector.IsPortalOpen(2), Is.True);
Assert.That(thirdPortal.text, Does.Not.Contain("미개방"));
world.WaveDirector.StopAllSupply();
hudTestHud.RefreshState();
Text twelfthPortal = FindText("Portal 12");
Assert.That(FindText("Summary").text, Does.Contain("다음 강적 공급 중단"));
Assert.That(thirdPortal.text, Does.Contain("공급 중단"));
Assert.That(thirdPortal.text, Does.Not.Contain("미개방"));
Assert.That(twelfthPortal.text, Does.Contain("미개방"));
Assert.That(twelfthPortal.text, Does.Contain("공급 중단"));
yield return null;
}
[UnityTest]
public IEnumerator EarlyCallButtonUsesWorldCommandAndCurrencyShowsSpendableAndKillTotalsSeparately()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(37f);
hudTestHud.RefreshState();
Button earlyCall = FindButton("Early Call Next Wave");
Defense25DWavePlan currentPlan = world.WaveDirector.GetCurrentPlan();
Assert.That(currentPlan, Is.Not.Null);
Assert.That(world.CombatElapsed,
Is.LessThan(currentPlan.ActualStart + world.RunDefinition.WaveDirectorConstants.SpawnWindowSeconds));
Assert.That(earlyCall.interactable, Is.False,
"Early call must stay disabled during the current wave spawn window.");
Assert.That(FindText("Summary").text, Does.Contain("스폰 창"));
world.AdvanceRunClock(1f);
Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running));
Assert.That(world.NotifyLongRunBossActivated(), Is.True);
world.TowerManager.AddCurrency(63);
hudTestHud.RefreshState();
Text summary = FindText("Summary");
Assert.That(summary.text, Does.Contain($"건설 재화 {world.TowerManager.ConstructionCurrency}"));
Assert.That(summary.text, Does.Contain($"처치 재화 {world.KillCurrency}"));
int currencyBeforeCall = world.TowerManager.ConstructionCurrency;
Assert.That(earlyCall.gameObject.activeInHierarchy, Is.True);
Assert.That(earlyCall.interactable, Is.True);
Assert.That(FindText("Summary").text, Does.Contain("Micro Break"));
earlyCall.onClick.Invoke();
Assert.That(world.TowerManager.ConstructionCurrency, Is.GreaterThan(currencyBeforeCall));
Assert.That(FindText("HUD Action Message").text, Does.Contain("조기 호출"));
Assert.That(FindText("Summary").text, Does.Contain("Micro Break 00:00"));
yield return null;
}
[UnityTest]
public IEnumerator RunSummaryShowsNextWaveThreatTelegraphAndBreakState()
{
yield return InitializeHudTestWorld(longRun: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(37f);
hudTestHud.RefreshState();
Assert.That(FindText("Summary").text, Does.Contain("스폰 창"));
world.AdvanceRunClock(1f);
hudTestHud.RefreshState();
Defense25DWavePlan current = world.WaveDirector.GetCurrentPlan();
Defense25DWavePlan next = world.WaveDirector.GetNextPlanPreview();
Assert.That(current, Is.Not.Null);
Assert.That(next, Is.Not.Null);
string summary = FindText("Summary").text;
Assert.That(summary, Does.Contain($"다음 Wave {next.WaveId}"));
Assert.That(summary, Does.Contain($"다음 위협 {next.ThreatBudget:0.#}"));
Assert.That(summary, Does.Contain("예고"));
Assert.That(summary, Does.Contain("Micro Break"));
Assert.That(world.WaveDirector.TryGetNextStrongEventPreview(
out Defense25DSpawnSource strongSource, out float nominalStrongStart), Is.True);
Assert.That(strongSource, Is.EqualTo(Defense25DSpawnSource.GlobalElite));
world.WaveDirector.RegisterReliefWindow("hud-next-strong-relief", nominalStrongStart, 12f);
Assert.That(world.WaveDirector.TryGetNextStrongEventPreview(out strongSource,
out float actualStrongStart), Is.True);
Assert.That(actualStrongStart, Is.GreaterThan(nominalStrongStart),
"The HUD preview must use the Director's relief-adjusted actual time.");
hudTestHud.RefreshState();
int strongCountdownSeconds = Mathf.Max(0,
Mathf.CeilToInt(actualStrongStart - world.CombatElapsed));
string expectedStrongCountdown =
$"{strongCountdownSeconds / 60:00}:{strongCountdownSeconds % 60:00}";
Assert.That(FindText("Summary").text,
Does.Contain($"다음 강적 엘리트 {expectedStrongCountdown}"));
world.WaveDirector.RegisterReliefWindow("hud-summary-relief",
world.CombatElapsed, 5f);
hudTestHud.RefreshState();
Assert.That(FindText("Summary").text, Does.Contain("구호 시간"));
Assert.That(FindButton("Early Call Next Wave").interactable, Is.False,
"Early call must be disabled while the Director is in a relief window.");
yield return null;
}
[UnityTest]
public IEnumerator RunSummaryUsesStrategyBreakScheduleWhenConfigured()
{
yield return InitializeHudTestWorld(longRun: true, strategyBreakEvery: 1);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
world.AdvanceRunClock(38f);
hudTestHud.RefreshState();
Assert.That(world.WaveDirector.GetCurrentPlan().WaveId, Is.EqualTo(1));
Assert.That(FindText("Summary").text, Does.Contain("Strategy Break 00:15"));
yield return null;
}
[UnityTest]
public IEnumerator MissionRowsShowRuntimeStateAndStartThroughWorldCommand()
{
yield return InitializeHudTestWorld(longRun: true, allPortalsOpen: true);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
Assert.That(world.PersonalMissionStates, Has.Count.EqualTo(3));
world.AdvanceRunClock(38f);
hudTestHud.RefreshState();
Button start = FindButton("Start Mission 1");
Assert.That(start.gameObject.activeInHierarchy, Is.True);
Assert.That(start.interactable, Is.True);
start.onClick.Invoke();
Assert.That(world.PersonalMissionStates[0].Status,
Is.EqualTo(Defense25DPersonalMissionStatus.Active));
Assert.That(FindText("Mission 1").text, Does.Contain("진행"));
Assert.That(start.gameObject.activeInHierarchy, Is.False);
yield return null;
}
[UnityTest]
public IEnumerator VictoryAndDefeatRestartImmediately()
{
yield return InitializeHudTestWorld(longRun: false);
Defense25DComparison world = hudTestWorldObject.GetComponent<Defense25DComparison>();
int generation = world.RunGeneration;
SetPrivateResult(world, "RunCleared", true);
hudTestHud.RefreshState();
FindButton("Restart Run").onClick.Invoke();
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
Assert.That(IsRestartConfirmationVisible(), Is.False);
generation = world.RunGeneration;
SetPrivateResult(world, "RunFailed", true);
hudTestHud.RefreshState();
FindButton("Restart Run").onClick.Invoke();
Assert.That(world.RunGeneration, Is.EqualTo(generation + 1));
Assert.That(IsRestartConfirmationVisible(), Is.False);
yield return null;
}
[UnityTearDown]
public IEnumerator DestroyViewTestObjects()
{
if (hudTestKeyboard != null && hudTestKeyboard.added)
{
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState());
InputSystem.RemoveDevice(hudTestKeyboard);
}
hudTestKeyboard = null;
if (hudTestObject != null)
Object.Destroy(hudTestObject);
if (hudTestWorldObject != null)
Object.Destroy(hudTestWorldObject);
if (hudTestSettings != null)
Object.DestroyImmediate(hudTestSettings);
if (hudTestRunDefinition != null)
Object.DestroyImmediate(hudTestRunDefinition);
if (hudTestWaveConstants != null)
Object.DestroyImmediate(hudTestWaveConstants);
if (hudTestRewardConstants != null)
Object.DestroyImmediate(hudTestRewardConstants);
if (hudTestModifier != null)
Object.DestroyImmediate(hudTestModifier);
hudTestHud = null;
hudTestObject = null;
hudTestWorldObject = null;
hudTestSettings = null;
hudTestRunDefinition = null;
hudTestWaveConstants = null;
hudTestProgression = null;
hudTestRewardConstants = null;
hudTestModifier = null;
if (hudInputSettingsCaptured)
{
InputSystem.settings.backgroundBehavior = previousBackgroundBehavior;
InputSystem.settings.editorInputBehaviorInPlayMode =
previousEditorInputBehavior;
Application.runInBackground = previousRunInBackground;
hudInputSettingsCaptured = false;
}
Time.timeScale = previousTimeScale;
yield return null;
}
private Defense25DRunHud hudTestHud;
private Keyboard hudTestKeyboard;
private IEnumerator InitializeHudTestWorld(bool longRun, bool allPortalsOpen = false,
int strategyBreakEvery = 0)
{
Time.timeScale = 0f;
hudTestSettings = Object.Instantiate(AssetDatabase.LoadAssetAtPath<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
hudTestWorldObject = new GameObject("Run HUD interaction world");
Defense25DComparison world = hudTestWorldObject.AddComponent<Defense25DComparison>();
yield return null;
if (longRun)
{
hudTestRunDefinition = Object.Instantiate(
AssetDatabase.LoadAssetAtPath<Defense25DRunDefinition>(
"Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"));
if (strategyBreakEvery > 0)
{
hudTestWaveConstants = Object.Instantiate(
hudTestRunDefinition.WaveDirectorConstants);
hudTestWaveConstants.StrategyBreakEvery = strategyBreakEvery;
hudTestRunDefinition.WaveDirectorConstants = hudTestWaveConstants;
}
hudTestRunDefinition.RewardConstants = AssetDatabase.LoadAssetAtPath<Defense25DRewardConstants>(
"Assets/_Project/Constants/Defense25DComparison/RewardConstants.asset");
EnemyDefinition testSpecialEnemy = AssetDatabase.LoadAssetAtPath<EnemyDefinition>(
"Assets/_Project/Constants/Enemies/Skeleton.asset");
Assert.That(testSpecialEnemy, Is.Not.Null,
"The long-run HUD fixture requires a valid source enemy definition.");
hudTestRunDefinition.GlobalEliteDefinition = testSpecialEnemy;
hudTestRunDefinition.GlobalMidBossDefinition = testSpecialEnemy;
hudTestRunDefinition.FinalBossDefinition = testSpecialEnemy;
hudTestRunDefinition.MonsterSourceMotionCatalog =
AssetDatabase.LoadAssetAtPath<Defense25DMonsterSourceMotionCatalog>(
"Assets/_Project/Constants/Defense25DComparison/MonsterSourceMotions.asset");
if (allPortalsOpen)
{
Defense25DPortalDefinition[] portals =
(Defense25DPortalDefinition[])hudTestRunDefinition.PortalDefinitions.Clone();
for (int i = 0; i < portals.Length; i++)
portals[i].OpenAt = 0f;
hudTestRunDefinition.PortalDefinitions = portals;
}
Assert.That(hudTestRunDefinition.MonsterSourceMotionCatalog, Is.Not.Null);
Assert.That(hudTestRunDefinition.Validate(out string validationError), Is.True,
validationError);
world.InitializeLongRun(hudTestSettings, hudTestRunDefinition);
}
else
world.Initialize(hudTestSettings);
world.AutoSpawn = false;
world.ReadInput = false;
hudTestObject = new GameObject("Run HUD interaction under test");
hudTestHud = hudTestObject.AddComponent<Defense25DRunHud>();
hudTestHud.Bind(world, null, null);
FieldInfo runHudField = typeof(Defense25DComparison).GetField("runHud",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(runHudField, Is.Not.Null,
"The HUD input fixture must use the world's actual presentation binding.");
runHudField.SetValue(world, hudTestHud);
yield return null;
}
private Button FindButton(string name)
{
foreach (Button button in hudTestObject.GetComponentsInChildren<Button>(true))
if (button.name == name)
return button;
Assert.Fail($"HUD button '{name}' was not created.");
return null;
}
private Text FindText(string name)
{
foreach (Text text in hudTestObject.GetComponentsInChildren<Text>(true))
if (text.name == name)
return text;
Assert.Fail($"HUD text '{name}' was not created.");
return null;
}
private static string GetButtonLabel(Button button)
{
Text label = button != null ? button.GetComponentInChildren<Text>(true) : null;
return label != null ? label.text : string.Empty;
}
private static int CountOccurrences(string source, string value)
{
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(value))
return 0;
return (source.Length - source.Replace(value, string.Empty).Length) / value.Length;
}
private void BeginHudKeyboardInput()
{
if (hudTestKeyboard == null || !hudTestKeyboard.added)
{
hudInputSettingsCaptured = true;
previousBackgroundBehavior = InputSystem.settings.backgroundBehavior;
previousEditorInputBehavior =
InputSystem.settings.editorInputBehaviorInPlayMode;
previousRunInBackground = Application.runInBackground;
InputSystem.settings.backgroundBehavior =
InputSettings.BackgroundBehavior.IgnoreFocus;
InputSystem.settings.editorInputBehaviorInPlayMode =
InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
Application.runInBackground = true;
hudTestKeyboard = InputSystem.AddDevice<Keyboard>();
hudTestKeyboard.MakeCurrent();
}
}
private IEnumerator PressHudKey(Key key)
{
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState(key));
yield return null;
Assert.That(hudTestKeyboard[key].isPressed, Is.True,
$"Queued {key} input must reach the synthetic keyboard on frame {Time.frameCount}.");
Assert.That(Keyboard.current, Is.SameAs(hudTestKeyboard),
$"HUD input must read the same synthetic keyboard that received {key}.");
Assert.That(hudTestKeyboard[key].wasPressedThisFrame, Is.True,
$"Queued {key} press edge must be visible to HUD LateUpdate on frame {Time.frameCount}.");
// HUD key handling is in LateUpdate. A UnityTest resumed from `yield return null`
// can assert during Update before that LateUpdate has consumed the press edge.
// Resume one frame later so caller assertions test the resulting HUD state.
yield return null;
}
private IEnumerator ReleaseHudKeys()
{
InputSystem.QueueStateEvent(hudTestKeyboard, new KeyboardState());
yield return null;
Assert.That(hudTestKeyboard.anyKey.isPressed, Is.False);
Assert.That(Keyboard.current, Is.SameAs(hudTestKeyboard),
"The synthetic keyboard that received key releases must remain current.");
// The HUD clears its held-A safety latch in LateUpdate after observing release.
// Let that frame finish before the caller checks the post-modal input state.
yield return null;
}
private bool IsRestartConfirmationVisible()
{
foreach (Button button in hudTestObject.GetComponentsInChildren<Button>(true))
if (button.name == "Confirm Restart")
return button.gameObject.activeInHierarchy;
return false;
}
private static ModifierDefinition FindFirstLevelUpModifier()
{
ModifierDefinition[] definitions = GameplayConstants.Current.LevelUp.ModifierDefinitions;
if (definitions == null)
return null;
for (int i = 0; i < definitions.Length; i++)
if (definitions[i] != null)
return definitions[i];
return null;
}
private static void SetPrivateResult(Defense25DComparison world, string property, bool value)
{
typeof(Defense25DComparison).GetProperty(property)
.GetSetMethod(true).Invoke(world, new object[] { value });
if (property == "RunCleared")
typeof(Defense25DComparison).GetProperty("RunFailed")
.GetSetMethod(true).Invoke(world, new object[] { false });
else
typeof(Defense25DComparison).GetProperty("RunCleared")
.GetSetMethod(true).Invoke(world, new object[] { false });
}
}
}