Files
MagnetismHeroes/Assets/MagnetismHeroes/Tests/PlayMode/PlaygroundSafetyPlayTests.cs
T

387 lines
29 KiB
C#

#if UNITY_EDITOR
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using MagnetismHeroes.UnityRuntime.Presentation;
using NUnit.Framework;
using UnityEditor;
using Unity.Collections;
using UnityEngine;
using UnityEngine.TestTools;
namespace MagnetismHeroes.Tests
{
public sealed class PlaygroundSafetyPlayTests
{
private sealed class MemoryStore : IProgressStore
{
public ProgressSave Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } };
public int Writes;
public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = null; return true; }
public bool TrySave(ProgressSave save, out string error) { Save = save; Writes++; error = null; return true; }
}
private GameApp _app;
private MemoryStore _store;
private readonly List<GameObject> _obstacles = new List<GameObject>();
[UnitySetUp] public IEnumerator Setup()
{
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
_store = new MemoryStore(); _app.Initialize(_store); _app.StartStage(6); _app.Voice.Clear();
// Explicit test wiring until the single GameApp writer connects this production reference.
_app.Presenter.BindEnvironment(_app.Environment);
// Keep the real clearing; isolate movement cases from the unrelated authored props.
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>())
if (!collider.name.Contains("Clearing")) collider.enabled = false;
yield return null;
}
[UnityTearDown] public IEnumerator Teardown()
{
foreach (var obstacle in _obstacles) Object.Destroy(obstacle);
_obstacles.Clear(); Object.Destroy(_app.gameObject); yield return null;
}
private void Inject(Vector3[] positions, params int[] sizes)
{
// Boundary state injection, not normal stage progression: free-play balls/groups/camera only.
var balls = positions.Select((point, index) => new BallState { id = "safety-" + index, position = BoardPresenter.ToPosition(point) }).ToArray();
int offset = 0;
var groups = sizes.Select((size, index) => { var ids = balls.Skip(offset).Take(size).Select(ball => ball.id).ToArray(); offset += size; return new GroupState { id = "group-" + index, ballIds = ids }; }).ToArray();
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = balls, groups = groups, camera = _app.Session.Camera };
_app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms();
}
private GameObject Wall(Vector3 center, Vector3 size, Quaternion rotation)
{
var wall = new GameObject("test-only-thin-obstacle"); wall.transform.SetPositionAndRotation(center, rotation);
wall.AddComponent<BoxCollider>().size = size; _obstacles.Add(wall); Physics.SyncTransforms(); return wall;
}
private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
private void AssertUnchanged(string snapshot) => Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)), Is.EqualTo(snapshot));
[UnityTest] public IEnumerator FastThinWallDragStopsThenReversesImmediatelyAndCommitsLastPreview()
{
Inject(new[] { new Vector3(0, 2, 0) }, 1);
var camera = _app.Presenter.Camera; var initial = _app.Presenter.Balls["safety-0"].transform.position;
Wall(initial + camera.transform.right * .8f, new Vector3(.02f, 12, 12), camera.transform.rotation);
var start = Point("safety-0"); var destination = start + new Vector2(150, 0);
_app.Input.Begin(start, false); _app.Input.Move(destination);
var stopped = _app.Presenter.Balls["safety-0"].transform.position;
float movement = Vector3.Dot(stopped - initial, camera.transform.right);
Assert.That(movement, Is.InRange(.1f, .291f), "A fast pointer move must sweep the sphere, not just test its endpoint.");
_app.Input.Move(destination - new Vector2(12, 0));
var reversed = _app.Presenter.Balls["safety-0"].transform.position;
Assert.That(Vector3.Dot(reversed - stopped, camera.transform.right), Is.LessThan(-.01f), "Reverse input must act at the stopped point without consuming the blocked delta.");
_app.Input.End(destination - new Vector2(12, 0)); yield return null;
Assert.That(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), Is.EqualTo(reversed));
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("safety-0"));
}
[UnityTest] public IEnumerator FastTenSphereTranslationStopsForTheLeadingMemberAndCommitsOneUnchangedShape()
{
var positions = Enumerable.Range(0, 10).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray();
Inject(positions, 10); Wall(new Vector3(3, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity);
_app.Presenter.PreviewGroup("group-0", Vector3.right * 20);
var delta = _app.Presenter.DropDelta;
Assert.That(delta.x, Is.InRange(.48f, .491f));
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
_app.CommitMove("group-0", BoardPresenter.ToPosition(delta)); yield return null;
Assert.That(_app.Session.Board.BallCount, Is.EqualTo(10));
Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EquivalentTo(preview.Keys));
foreach (var ball in _app.Session.Board.Balls)
{
Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
int index = int.Parse(ball.id.Substring(7)); Assert.That(preview[ball.id], Is.EqualTo(positions[index] + delta));
}
}
[UnityTest] public IEnumerator SingleAndTenSpheresStopAtAllSixFiniteBoundsWithoutChangingShape()
{
foreach (int count in new[] { 1, 10 })
foreach (var direction in new[] { Vector3.left, Vector3.right, Vector3.forward, Vector3.back, Vector3.up, Vector3.down })
{
var positions = Enumerable.Range(0, count).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray();
Inject(positions, count); _app.Presenter.PreviewGroup("group-0", direction * 40);
var delta = _app.Presenter.DropDelta;
Assert.That(delta.magnitude, Is.GreaterThan(.1f)); Assert.That(delta.magnitude, Is.LessThan(40));
var bounds = _app.Environment.PlaygroundBounds;
foreach (var ball in _app.Presenter.Balls.Values)
{
var point = ball.transform.position;
Assert.That(point.x - .5f, Is.GreaterThanOrEqualTo(bounds.min.x - .002f));
Assert.That(point.x + .5f, Is.LessThanOrEqualTo(bounds.max.x + .002f));
Assert.That(point.y - .5f, Is.GreaterThanOrEqualTo(bounds.min.y - .002f));
Assert.That(point.y + .5f, Is.LessThanOrEqualTo(bounds.max.y + .002f));
Assert.That(point.z - .5f, Is.GreaterThanOrEqualTo(bounds.min.z - .002f));
Assert.That(point.z + .5f, Is.LessThanOrEqualTo(bounds.max.z + .002f));
var index = int.Parse(ball.BallId.Substring(7));
Assert.That(Vector3.Distance(point, positions[index] + delta), Is.LessThan(.0001f));
}
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True);
_app.Presenter.CancelPreview(); yield return null;
}
}
[UnityTest] public IEnumerator ActualGroundStopsTheFullRadiusAndSmallFoliageAllowsMovement()
{
Inject(new[] { new Vector3(0, 2, 0) }, 1);
Assert.That(Physics.Raycast(new Vector3(0, 2, 0), Vector3.down, out var ground, 10), Is.True);
_app.Presenter.PreviewGroup("group-0", Vector3.down * 20);
Assert.That(_app.Presenter.Balls["safety-0"].transform.position.y, Is.GreaterThanOrEqualTo(ground.point.y + .499f));
Assert.That(_app.Presenter.DropDelta.y, Is.LessThan(-.1f)); _app.Presenter.CancelPreview();
var foliage = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/GrassTuftMint.prefab"));
foliage.transform.position = new Vector3(1, 2, 0); _obstacles.Add(foliage);
Assert.That(foliage.GetComponentsInChildren<Collider>(), Is.Empty);
_app.Presenter.PreviewGroup("group-0", Vector3.right * 2);
Assert.That(_app.Presenter.DropDelta, Is.EqualTo(Vector3.right * 2)); yield return null;
}
[UnityTest] public IEnumerator ContactCorrectionCannotCrossThinObstacleOrOfferFalseDepthMerge()
{
Inject(new[] { new Vector3(-1, 2, 0), new Vector3(1, 2, 0) }, 1, 1);
Wall(new Vector3(0, 2, 0), new Vector3(.02f, 10, 10), Quaternion.identity);
_app.Presenter.PreviewGroup("group-0", Vector3.right * 2);
Assert.That(_app.Presenter.Balls["safety-0"].transform.position.x, Is.LessThanOrEqualTo(-.509f));
Assert.That(_app.Presenter.MergeCandidate, Is.Null);
_app.Presenter.CancelPreview(); _obstacles[0].SetActive(false);
Inject(new[] { new Vector3(0, 2, 0), new Vector3(0, 2, 2) }, 1, 1);
_app.Presenter.PreviewGroup("group-0", Vector3.right * .1f);
Assert.That(_app.Presenter.MergeCandidate, Is.Null, "Projected overlap is not three-dimensional contact."); yield return null;
}
[UnityTest] public IEnumerator SafePointerMoveRejectsContactSnapIntoAThinWall()
{
Inject(new[] { new Vector3(-1.2f, 2, 0), new Vector3(0, 2, 0) }, 1, 1);
Wall(new Vector3(-.6f, 2, 0), new Vector3(.02f, 10, 10), Quaternion.identity);
_app.Presenter.PreviewGroup("group-0", Vector3.right * .01f);
Assert.That(_app.Presenter.DropDelta.x, Is.EqualTo(.01f).Within(.0001f));
Assert.That(_app.Presenter.MergeCandidate, Is.Null, "The contact snap must sweep and reject wall penetration even when the pointer path is safe.");
Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.ContactCandidate), Is.True); yield return null;
}
private void SelectSplitBall(string id)
{
var point = Point(id); _app.Input.Begin(point, false); _app.Input.End(point);
_app.Input.BeginSplit(); _app.Input.Begin(point, false); _app.Input.End(point);
Assert.That(_app.Input.SelectedIds, Does.Contain(id));
}
[UnityTest] public IEnumerator SplitFirstClearanceJumpCannotTeleportThroughAThinWall()
{
var positions = Enumerable.Range(0, 10).Select(index => new Vector3(index % 5 - 2, 2 + index / 5, 0)).ToArray();
Inject(positions, 10); Wall(new Vector3(-4.9f, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity);
Assert.That(_app.Session.Board.PreviewSplit("group-0", new[] { "safety-0" }, new Position3(-.5f, 0, 0)).All(ball => _app.Presenter.IsSafePosition(ball.position)), Is.True, "The Core final arrays are safe; only their drag path crosses the wall.");
SelectSplitBall("safety-0");
var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)); var writes = _store.Writes;
var point = Point("safety-0"); _app.Input.Begin(point, false); _app.Input.Move(point - new Vector2(30, 0));
Assert.That(_app.Presenter.DropDelta, Is.EqualTo(Vector3.zero));
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False);
_app.Input.End(point - new Vector2(30, 0)); yield return null;
AssertUnchanged(snapshot); Assert.That(_store.Writes, Is.EqualTo(writes));
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
}
[UnityTest] public IEnumerator SplitBlockedPathKeepsExactValidDeltaThenReversesAndCommitsItsPreview()
{
Inject(new[] { new Vector3(-3, 2, 0), new Vector3(-2, 2, 0), new Vector3(0, 2, 0) }, 3);
Wall(new Vector3(1.2f, 2, 0), new Vector3(.02f, 12, 12), Quaternion.identity); SelectSplitBall("safety-2");
var point = Point("safety-2"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(20, 0));
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True);
var validDelta = _app.Presenter.DropDelta; var validPoint = _app.Presenter.Balls["safety-2"].transform.position;
_app.Input.Move(point + new Vector2(140, 0));
Assert.That(_app.Presenter.DropDelta, Is.EqualTo(validDelta));
Assert.That(_app.Presenter.Balls["safety-2"].transform.position, Is.EqualTo(validPoint));
_app.Input.Move(point + new Vector2(128, 0));
var reverse = _app.Presenter.Balls["safety-2"].transform.position;
Assert.That(reverse.x, Is.LessThan(validPoint.x - .01f));
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
_app.Input.End(point + new Vector2(128, 0)); yield return null;
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
}
[UnityTest] public IEnumerator SplitRejectsTheSelectedHalfBeyondEveryBoundary()
{
foreach (var direction in new[] { Vector3.left, Vector3.right, Vector3.forward, Vector3.back, Vector3.up, Vector3.down })
{
Inject(new[] { new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0) }, 3);
var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera));
Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-0" }, direction * 40), Is.False);
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False);
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
AssertUnchanged(snapshot); yield return null;
}
}
[UnityTest] public IEnumerator ApprovedClosedRockDeepInteriorIsRejectedAndProbeDiagnosticsAreRecorded()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/SlateBoulder.prefab");
var rock = Object.Instantiate(prefab, _app.Environment.transform); _obstacles.Add(rock);
// Approved runtime mesh, synthetic enlarged pose to put the entire unit sphere deep inside.
rock.transform.localScale = Vector3.one * 3;
var collider = rock.GetComponentInChildren<MeshCollider>();
Physics.SyncTransforms(); rock.transform.position += new Vector3(0, 3, 0) - collider.bounds.center; Physics.SyncTransforms();
var point = collider.bounds.center;
var source = _app.Presenter.Balls.Values.First().GetComponent<SphereCollider>();
bool penetration = Physics.ComputePenetration(source, point, Quaternion.identity, collider, collider.transform.position, collider.transform.rotation, out _, out float depth);
bool overlap = Physics.OverlapSphere(point, .5f, ~0, QueryTriggerInteraction.Ignore).Contains(collider);
Debug.Log($"Deep approved mesh {collider.name}: center={point:R} bounds={collider.bounds} overlap={overlap} penetration={penetration} depth={depth:R} safe={_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point))}");
var wall = Wall(new Vector3(0, 2, 0), Vector3.one, Quaternion.identity).GetComponent<BoxCollider>();
var diagnostic = new GameObject("test-only-probe-diagnostic"); _obstacles.Add(diagnostic);
var sphere = diagnostic.AddComponent<SphereCollider>(); sphere.radius = .5f; sphere.enabled = false;
bool disabledResult = Physics.ComputePenetration(sphere, wall.bounds.center, Quaternion.identity, wall, wall.transform.position, wall.transform.rotation, out _, out float disabledDepth);
sphere.enabled = true; sphere.isTrigger = true; diagnostic.layer = 2; Physics.SyncTransforms();
bool triggerResult = Physics.ComputePenetration(sphere, wall.bounds.center, Quaternion.identity, wall, wall.transform.position, wall.transform.rotation, out _, out float triggerDepth);
Debug.Log($"Probe direct comparison: disabled={disabledResult} depth={disabledDepth:R}; liveTrigger={triggerResult} depth={triggerDepth:R}");
bool backfaces = Physics.queriesHitBackfaces;
Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False, "Old-save safety must reject the deep interior of an approved closed rock mesh.");
Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(backfaces));
Physics.queriesHitBackfaces = !backfaces;
try
{
Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False);
Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(!backfaces));
}
finally { Physics.queriesHitBackfaces = backfaces; }
yield return null;
}
[UnityTest] public IEnumerator ApprovedArchHoleAcceptsFullSphereAndOverlappingTreeSolidsRejectInterior()
{
var arch = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/OpenStoneArch.prefab"), _app.Environment.transform);
_obstacles.Add(arch); arch.transform.localScale = Vector3.one * 2;
var shell = arch.GetComponentInChildren<MeshCollider>(); Physics.SyncTransforms();
arch.transform.position += new Vector3(0, 3, 0) - shell.bounds.center; Physics.SyncTransforms();
bool foundHole = false;
for (float y = Mathf.Max(.6f, shell.bounds.min.y + .55f); y < shell.bounds.max.y - .55f; y += .2f)
{
var point = new Vector3(shell.bounds.center.x, y, shell.bounds.center.z);
var start = point - Vector3.forward * (shell.bounds.extents.z + 1);
if (Physics.SphereCastAll(start, .5f, Vector3.forward, shell.bounds.size.z + 2, ~0, QueryTriggerInteraction.Ignore).Any(hit => hit.collider == shell)) continue;
Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.True, "The actual visible arch opening must accept a whole sphere.");
foundHole = true; Debug.Log("Approved arch unit-sphere hole point=" + point); break;
}
Assert.That(foundHole, Is.True); arch.SetActive(false);
var tree = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/Meadow/RoundCanopyTree.prefab"), _app.Environment.transform);
_obstacles.Add(tree); tree.transform.localScale = Vector3.one * 2;
var mesh = tree.GetComponentInChildren<MeshCollider>(); Physics.SyncTransforms();
tree.transform.position += new Vector3(0, 3, 0) - mesh.bounds.center; Physics.SyncTransforms();
// The approved tree includes intersecting closed crown volumes. Search its central volume for two enclosing pieces.
Vector3[] vertices; int[] triangles;
using (var readOnly = MeshUtility.AcquireReadOnlyMeshData(mesh.sharedMesh))
{
var data = readOnly[0]; var buffer = new NativeArray<Vector3>(data.vertexCount, Allocator.Temp);
data.GetVertices(buffer); vertices = buffer.ToArray(); buffer.Dispose();
triangles = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16
? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray()
: data.GetIndexData<uint>().ToArray().Select(index => (int)index).ToArray();
}
bool foundOverlap = false;
for (float y = Mathf.Max(.6f, mesh.bounds.min.y + .5f); y < Mathf.Min(5.8f, mesh.bounds.max.y - .5f); y += .2f)
{
var point = new Vector3(mesh.bounds.center.x, y, mesh.bounds.center.z);
int exits = 0, entries = 0; var direction = new Vector3(1, .371f, .529f).normalized;
for (int index = 0; index < triangles.Length; index += 3)
{
var a = mesh.transform.TransformPoint(vertices[triangles[index]]);
var b = mesh.transform.TransformPoint(vertices[triangles[index + 1]]);
var c = mesh.transform.TransformPoint(vertices[triangles[index + 2]]);
var edge1 = b - a; var edge2 = c - a; var cross = Vector3.Cross(direction, edge2);
float determinant = Vector3.Dot(edge1, cross);
if (Mathf.Abs(determinant) < .000001f) continue;
var offset = point - a; float u = Vector3.Dot(offset, cross) / determinant;
var q = Vector3.Cross(offset, edge1); float v = Vector3.Dot(direction, q) / determinant;
float t = Vector3.Dot(edge2, q) / determinant;
if (u < 0 || v < 0 || u + v > 1 || t <= .001f) continue;
if (Vector3.Dot(Vector3.Cross(edge1, edge2), direction) > 0) exits++; else entries++;
}
if (exits - entries < 2) continue;
foundOverlap = true; bool backfaces = Physics.queriesHitBackfaces;
Assert.That(_app.Presenter.IsSafePosition(BoardPresenter.ToPosition(point)), Is.False, "Overlapping approved closed tree parts are solid even with an even crossing count.");
Assert.That(Physics.queriesHitBackfaces, Is.EqualTo(backfaces));
Debug.Log($"Approved tree overlapping solids point={point} entries={entries} exits={exits}"); break;
}
Assert.That(foundOverlap, Is.True, "The approved tree fixture must actually include two overlapping closed volumes."); yield return null;
}
[UnityTest] public IEnumerator EveryOriginalStageBoardIsSafeAgainstActualSceneryAndFullSphereBounds()
{
for (int stage = 1; stage <= 30; stage++)
{
_app.StartStage(stage); _app.Voice.Clear(); yield return null;
Physics.SyncTransforms();
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True, "Original authored board stage " + stage);
foreach (var ball in _app.Session.Board.Balls)
Assert.That(_app.Presenter.IsSafePosition(ball.position), Is.True, "Stage " + stage + " sphere " + ball.id + " at " + BoardPresenter.ToVector(ball.position));
}
}
[UnityTest] public IEnumerator SafeTwoPlusThreeContactAndCommitPreserveAllIdsAndBothShapes()
{
var positions = new[] { new Vector3(-3, 2, 0), new Vector3(-2, 2, 0), new Vector3(1, 2, 0), new Vector3(2, 2, 0), new Vector3(3, 2, 0) };
Inject(positions, 2, 3); _app.Presenter.PreviewGroup("group-0", Vector3.right * 2.2f);
Assert.That(_app.Presenter.MergeCandidate, Is.EqualTo("group-1"));
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
_app.CommitMove("group-0", BoardPresenter.ToPosition(_app.Presenter.DropDelta), _app.Presenter.MergeCandidate); yield return null;
Assert.That(_app.Session.Board.Groups.Single().ballIds.Length, Is.EqualTo(5));
foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
Assert.That(Vector3.Distance(preview["safety-0"], preview["safety-1"]), Is.EqualTo(1).Within(.001f));
}
[UnityTest] public IEnumerator SplitRejectsUnsafeUnselectedHalfAndRestoresTheCommittedBoard()
{
Inject(new[] { new Vector3(-2, 2, 0), new Vector3(0, 2, 0), new Vector3(2, 2, 0) }, 3);
// Rest's compact arrangement moves from (-2, 2) to (-.5, .5); block just that final half.
Wall(new Vector3(-.5f, 2, 0), new Vector3(.05f, .05f, .05f), Quaternion.identity);
var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera));
Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-1" }, Vector3.up * 2), Is.False);
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.False);
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
AssertUnchanged(snapshot); yield return null;
}
[UnityTest] public IEnumerator SafeSplitBothHalvesMatchCommittedCoreArrangement()
{
Inject(new[] { new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0) }, 3);
var delta = Vector3.up * 2;
Assert.That(_app.Presenter.PreviewSplit("group-0", new[] { "safety-0" }, delta), Is.True);
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
Assert.That(_app.Presenter.CanDropGroup("group-0"), Is.True);
Assert.That(_app.CommitSplit("group-0", new[] { "safety-0" }, BoardPresenter.ToPosition(delta)), Is.True); yield return null;
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
}
[UnityTest] public IEnumerator SupplyUsesFiniteSafeSpaceAndNoSpaceLeavesIdsAndSaveUntouched()
{
Inject(new[] { new Vector3(0, 2, 0) }, 1);
_app.SpawnFriend(); Assert.That(_app.Session.Board.BallCount, Is.EqualTo(2));
var spawned = _app.Session.Board.Balls.Single(ball => ball.id != "safety-0"); var point = BoardPresenter.ToVector(spawned.position);
var bounds = _app.Environment.PlaygroundBounds;
Assert.That(bounds.Contains(point + Vector3.up * .5f) && bounds.Contains(point - Vector3.up * .5f), Is.True);
Wall(bounds.center, bounds.size * 2, Quaternion.identity);
var snapshot = JsonUtility.ToJson(_store.Save); var writes = _store.Writes;
_app.SpawnFriend(); Assert.That(_app.Session.Board.BallCount, Is.EqualTo(2));
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(snapshot)); Assert.That(_store.Writes, Is.EqualTo(writes));
Assert.That(_app.Instruction, Does.Contain("빈 곳")); yield return null;
}
[UnityTest] public IEnumerator CancelUIOutsidePinchPauseAndNavigationRestoreIdsCountsAndPositions()
{
foreach (var mode in new[] { "cancel", "ui", "outside", "pinch", "pause", "navigation" })
{
Inject(new[] { new Vector3(0, 2, 0) }, 1);
var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera));
var point = Point("safety-0"); _app.Input.Begin(point, mode == "ui"); _app.Input.Move(point + new Vector2(60, 0));
switch (mode)
{
case "cancel": _app.Input.End(point, true); break;
case "ui": _app.Input.End(point); break;
case "outside": _app.Input.End(new Vector2(-1, point.y)); break;
case "pinch": _app.Input.BeginPinch(100, false); _app.Input.Cancel(); break;
case "pause": _app.Input.SendMessage("OnApplicationPause", true); break;
case "navigation": _app.ShowHome(); _app.ContinueFreePlay(); break;
}
AssertUnchanged(snapshot);
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
yield return null;
}
}
[UnityTest] public IEnumerator FrontPropBlocksSelectingTheSphereBehindIt()
{
Inject(new[] { new Vector3(0, 2, 0) }, 1);
var point = Point("safety-0"); var ray = _app.Presenter.Camera.ScreenPointToRay(point);
Wall(ray.GetPoint(Vector3.Distance(ray.origin, new Vector3(0, 2, 0)) - 1), new Vector3(.5f, .5f, .5f), Quaternion.identity);
var snapshot = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera));
_app.Input.Begin(point, false); _app.Input.End(point);
Assert.That(_app.Input.SelectedGroup, Is.Null); AssertUnchanged(snapshot); yield return null;
}
}
}
#endif