using System;
using Sandbox.Mapping;
using Sandbox.Network;
namespace SceneTests.Components;
///
/// Shared helpers for the gameplay component tests.
///
internal static class GameComponentTestUtils
{
///
/// Pushes a Time scope pinned to the scene's own clock. TimeSince fields captured
/// outside of a GameTick (component construction, calls like Door.Open) record the
/// ambient global Time.Now, which has no relation to the scene clock that drives
/// OnFixedUpdate - this scope makes those captures line up with the scene clock so
/// timed state machines behave deterministically in tests.
///
public static IDisposable PushSceneClock( Scene scene )
{
return Time.Scope( scene.TimeNow, 0.02 );
}
///
/// Ticks the scene until the condition holds, giving up after maxTicks. The caller
/// asserts the condition afterwards so a failure reports properly.
///
public static void TickUntil( Scene scene, Func condition, int maxTicks )
{
for ( int i = 0; i < maxTicks && !condition(); i++ )
{
scene.GameTick();
}
}
///
/// Serializes a GameObject and deserializes it into a fresh disabled GameObject in
/// the same scene, re-iding the copy so both can coexist.
///
public static GameObject RoundTrip( GameObject go )
{
var node = go.Serialize();
SceneUtility.MakeIdGuidsUnique( node );
var copy = new GameObject( false );
copy.Deserialize( node );
return copy;
}
}
///
/// Pins the Prop component contract: procedural renderer/collider/rigidbody creation
/// from the model, the static and ragdoll paths, health and damage handling through
/// IDamageable, break callbacks, and serialization of the configured values.
///
[TestClass]
public class PropDamageTest
{
static Model ArrowModel => Model.Load( "models/arrow.vmdl" );
static Model CitizenModel => Model.Load( "models/citizen/citizen.vmdl" );
///
/// A model with a single physics part builds a renderer, a non-static
/// ModelCollider and a Rigidbody. A model without prop data leaves Health at zero.
///
[TestMethod]
public void SingleBodyModelBuildsRendererAndRigidbody()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
var renderer = go.Components.Get();
Assert.IsNotNull( renderer, "a renderer should be created from the model" );
Assert.AreEqual( ArrowModel, renderer.Model );
var collider = go.Components.Get();
Assert.IsNotNull( collider, "a model collider should be created" );
Assert.IsFalse( collider.Static );
var rb = go.Components.Get();
Assert.IsNotNull( rb, "a rigidbody should be created for a dynamic prop" );
Assert.IsTrue( rb.PhysicsBody.IsValid() );
Assert.AreEqual( 0f, prop.Health, "a model without prop data shouldn't set health" );
}
///
/// A static prop builds only a static collider - no rigidbody is created.
///
[TestMethod]
public void StaticPropBuildsStaticColliderOnly()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create( false );
prop.IsStatic = true;
prop.Model = ArrowModel;
prop.Enabled = true;
Assert.IsNotNull( go.Components.Get() );
var collider = go.Components.Get();
Assert.IsNotNull( collider );
Assert.IsTrue( collider.Static, "a static prop's collider should be static" );
Assert.IsNull( go.Components.Get(), "a static prop must not create a rigidbody" );
}
///
/// A model with multiple physics parts (a ragdoll) builds a SkinnedModelRenderer
/// and a ModelPhysics instead of the single collider/rigidbody pair.
///
[TestMethod]
public void RagdollModelBuildsModelPhysics()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = CitizenModel;
Assert.IsNotNull( go.Components.Get(), "a skinned model needs a skinned renderer" );
var physics = go.Components.Get();
Assert.IsNotNull( physics, "a multi-part model should create ModelPhysics" );
Assert.AreEqual( CitizenModel, physics.Model );
Assert.IsTrue( physics.Bodies.Count > 0 );
}
///
/// Setting Tint on the prop flows through to the procedural renderer.
///
[TestMethod]
public void TintFlowsToRenderer()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
prop.Tint = Color.Red;
Assert.AreEqual( Color.Red, go.Components.Get().Tint );
}
///
/// Non-lethal damage reduces Health, fires the take-damage callback and records
/// the attacker, without breaking the prop.
///
[TestMethod]
public void DamageReducesHealthAndTracksAttacker()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var attacker = scene.CreateObject();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
prop.Health = 100f;
int damaged = 0;
int broken = 0;
prop.OnPropTakeDamage = _ => damaged++;
prop.OnPropBreak = () => broken++;
prop.OnDamage( new DamageInfo { Damage = 30f, Attacker = attacker } );
Assert.AreEqual( 70f, prop.Health, 0.01f );
Assert.AreEqual( 1, damaged );
Assert.AreEqual( 0, broken );
Assert.AreEqual( attacker, prop.LastAttacker );
scene.ProcessDeletes();
Assert.IsTrue( go.IsValid(), "a surviving prop must not be destroyed" );
}
///
/// A prop at zero health ignores further damage - no callback fires and health
/// stays at zero - but the attacker is still recorded before the health check.
///
[TestMethod]
public void DamageIgnoredAtZeroHealth()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var attacker = scene.CreateObject();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
int damaged = 0;
prop.OnPropTakeDamage = _ => damaged++;
Assert.AreEqual( 0f, prop.Health );
prop.OnDamage( new DamageInfo { Damage = 50f, Attacker = attacker } );
Assert.AreEqual( 0f, prop.Health );
Assert.AreEqual( 0, damaged, "the dead feel nothing" );
Assert.AreEqual( attacker, prop.LastAttacker, "the attacker is recorded even on a dead prop" );
scene.ProcessDeletes();
Assert.IsTrue( go.IsValid() );
}
///
/// Damage that takes health to zero kills the prop: the break callback runs,
/// health clamps to zero and the GameObject is destroyed.
///
[TestMethod]
public void LethalDamageBreaksAndDestroys()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
prop.Health = 10f;
int damaged = 0;
int broken = 0;
prop.OnPropTakeDamage = _ => damaged++;
prop.OnPropBreak = () => broken++;
prop.OnDamage( new DamageInfo { Damage = 25f } );
Assert.AreEqual( 0f, prop.Health, "health clamps to zero on death" );
Assert.AreEqual( 1, damaged, "the take damage callback fires before the kill" );
Assert.AreEqual( 1, broken );
scene.ProcessDeletes();
Assert.IsFalse( go.IsValid(), "a killed prop destroys its GameObject" );
}
///
/// A prop's configured values survive a serialize/deserialize round trip.
///
[TestMethod]
public void SerializedPropKeepsConfiguredValues()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var prop = go.Components.Create();
prop.Model = ArrowModel;
prop.Health = 42f;
prop.Tint = Color.Red;
prop.StartAsleep = true;
var copy = GameComponentTestUtils.RoundTrip( go );
var prop2 = copy.Components.Get( true );
Assert.IsNotNull( prop2 );
Assert.AreEqual( ArrowModel, prop2.Model );
Assert.AreEqual( 42f, prop2.Health, 0.01f );
Assert.AreEqual( Color.Red, prop2.Tint );
Assert.IsTrue( prop2.StartAsleep );
}
}
///
/// Pins the damage-dealing components: TriggerHurt periodic damage with tag filters,
/// RadiusDamage distance falloff and physics push, and FireDamage's fixed-interval
/// burn against the root object.
///
[TestClass]
public class TriggerDamageTest
{
Connection _previousLocalConnection;
NetworkSystem _previousNetworkSystem;
///
/// Pins Connection.Local to a host connection and clears Networking.System so the
/// host-gated damage paths (TriggerHurt's Networking.IsHost check) run locally
/// instead of being silently skipped when another test in the assembly has leaked
/// a non-host client connection. Same idiom as ChairTests.cs.
///
[TestInitialize]
public void PinHostNetworkingState()
{
_previousLocalConnection = Connection.Local;
_previousNetworkSystem = Networking.System;
Connection.Local = new TestConnection( Guid.NewGuid(), isHost: true );
Networking.System = null;
}
///
/// Restores whatever global networking state existed before the test, leaving the
/// rest of the assembly exactly as it was.
///
[TestCleanup]
public void RestoreNetworkingState()
{
Connection.Local = _previousLocalConnection;
Networking.System = _previousNetworkSystem;
}
///
/// Counts damage delivered through IDamageable, recording the per-event amount
/// because RadiusDamage restores the DamageInfo's Damage after the loop.
///
public sealed class DamageCounter : Component, Component.IDamageable
{
public int Events { get; private set; }
public float TotalDamage { get; private set; }
public float LastAmount { get; private set; }
public DamageInfo Last { get; private set; }
public void OnDamage( in DamageInfo damage )
{
Events++;
TotalDamage += damage.Damage;
LastAmount = damage.Damage;
Last = damage;
}
}
///
/// Creates a non-falling rigidbody box with a damage counter at the position.
///
static (GameObject go, DamageCounter counter) CreateVictim( Scene scene, Vector3 position, params string[] tags )
{
var go = scene.CreateObject();
go.WorldPosition = position;
foreach ( var tag in tags )
{
go.Tags.Add( tag );
}
var rb = go.Components.Create();
rb.Gravity = false;
var box = go.Components.Create();
box.Scale = new Vector3( 10 );
var counter = go.Components.Create();
return (go, counter);
}
///
/// A body resting inside a TriggerHurt takes damage periodically - more than once
/// over time, but rate limited well below once per fixed update - and the damage
/// info carries the attacker, amount and configured tags.
///
[TestMethod]
public void TriggerHurtAppliesPeriodicDamage()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var triggerGo = scene.CreateObject();
var triggerBox = triggerGo.Components.Create();
triggerBox.Scale = new Vector3( 100 );
triggerBox.IsTrigger = true;
TriggerHurt hurt;
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
hurt = triggerGo.Components.Create();
}
hurt.Damage = 5f;
hurt.Rate = 0.5f;
hurt.DamageTags.Add( "burny" );
var (_, counter) = CreateVictim( scene, Vector3.Zero );
for ( int i = 0; i < 35; i++ ) scene.GameTick();
Assert.IsTrue( counter.Events >= 2, $"damage should repeat over time: {counter.Events}" );
Assert.IsTrue( counter.Events <= 9, $"damage must be rate limited: {counter.Events}" );
Assert.AreEqual( 5f, counter.LastAmount, 0.01f );
Assert.AreEqual( triggerGo, counter.Last.Attacker );
Assert.IsTrue( counter.Last.Tags.Has( "burny" ) );
}
///
/// With Include tags set, only targets carrying one of those tags take damage.
///
[TestMethod]
public void TriggerHurtIncludeFilter()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var triggerGo = scene.CreateObject();
var triggerBox = triggerGo.Components.Create();
triggerBox.Scale = new Vector3( 100 );
triggerBox.IsTrigger = true;
TriggerHurt hurt;
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
hurt = triggerGo.Components.Create();
}
hurt.Rate = 0.1f;
hurt.Include.Add( "food" );
var (_, tagged) = CreateVictim( scene, new Vector3( 20, 0, 0 ), "food" );
var (_, untagged) = CreateVictim( scene, new Vector3( -20, 0, 0 ) );
for ( int i = 0; i < 20; i++ ) scene.GameTick();
Assert.IsTrue( tagged.Events > 0, "the included target should be damaged" );
Assert.AreEqual( 0, untagged.Events, "targets without an include tag are skipped" );
}
///
/// With Exclude tags set, targets carrying one of those tags are spared.
///
[TestMethod]
public void TriggerHurtExcludeFilter()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var triggerGo = scene.CreateObject();
var triggerBox = triggerGo.Components.Create();
triggerBox.Scale = new Vector3( 100 );
triggerBox.IsTrigger = true;
TriggerHurt hurt;
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
hurt = triggerGo.Components.Create();
}
hurt.Rate = 0.1f;
hurt.Exclude.Add( "god" );
var (_, godly) = CreateVictim( scene, new Vector3( 20, 0, 0 ), "god" );
var (_, mortal) = CreateVictim( scene, new Vector3( -20, 0, 0 ) );
for ( int i = 0; i < 20; i++ ) scene.GameTick();
Assert.AreEqual( 0, godly.Events, "excluded targets are spared" );
Assert.IsTrue( mortal.Events > 0, "everyone else gets hurt" );
}
///
/// RadiusDamage applies on enable by default and falls off linearly with distance.
/// The shared DamageInfo's Damage is restored to the full amount after the loop -
/// only the value passed at call time carries the falloff.
///
[TestMethod]
public void RadiusDamageFalloffByDistance()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var attacker = scene.CreateObject();
var (_, near) = CreateVictim( scene, new Vector3( 10, 0, 0 ) );
var (_, far) = CreateVictim( scene, new Vector3( 100, 0, 0 ) );
var rdGo = scene.CreateObject();
var rd = rdGo.Components.Create( false );
rd.Radius = 200f;
rd.DamageAmount = 100f;
rd.Occlusion = false;
rd.Attacker = attacker;
rd.DamageTags.Add( "explosion" );
rd.Enabled = true;
Assert.AreEqual( 1, near.Events );
Assert.AreEqual( 1, far.Events );
Assert.AreEqual( 95f, near.LastAmount, 0.5f, "a target near the center takes almost full damage" );
Assert.AreEqual( 50f, far.LastAmount, 0.5f, "a target at half the radius takes half damage" );
Assert.AreEqual( attacker, far.Last.Attacker );
Assert.AreEqual( rdGo, far.Last.Weapon );
Assert.IsTrue( far.Last.Origin.AlmostEqual( Vector3.Zero ) );
Assert.IsTrue( far.Last.Tags.Has( "explosion" ) );
// The DamageInfo instance is shared - its Damage is restored to the full
// amount after all targets have been damaged.
Assert.AreEqual( 100f, far.Last.Damage, 0.5f );
}
///
/// RadiusDamage pushes dynamic rigidbodies away from the center.
///
[TestMethod]
public void RadiusDamagePushesRigidbodies()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
go.WorldPosition = new Vector3( 50, 0, 0 );
var rb = go.Components.Create();
rb.Gravity = false;
var box = go.Components.Create();
box.Scale = new Vector3( 10 );
var rdGo = scene.CreateObject();
var rd = rdGo.Components.Create( false );
rd.Radius = 200f;
rd.DamageAmount = 0f;
rd.Occlusion = false;
rd.Enabled = true;
// The push is a force - it integrates on the next physics step
scene.GameTick();
Assert.IsTrue( rb.Velocity.x > 0f, $"the body should be pushed away from the center: {rb.Velocity}" );
}
///
/// With DamageOnEnabled off nothing happens on enable - damage only applies when
/// Apply is called explicitly.
///
[TestMethod]
public void RadiusDamageManualApply()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, counter) = CreateVictim( scene, new Vector3( 50, 0, 0 ) );
var rdGo = scene.CreateObject();
var rd = rdGo.Components.Create( false );
rd.Radius = 200f;
rd.DamageAmount = 40f;
rd.DamageOnEnabled = false;
rd.Occlusion = false;
rd.Enabled = true;
Assert.AreEqual( 0, counter.Events, "no damage should apply on enable" );
rd.Apply();
Assert.AreEqual( 1, counter.Events );
Assert.AreEqual( 30f, counter.LastAmount, 0.5f, "40 damage at a quarter of the radius leaves 30" );
}
///
/// With occlusion enabled and nothing tagged "map" in the way, the line of sight
/// check passes and damage still applies.
///
[TestMethod]
public void RadiusDamageWithOcclusionStillHits()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, counter) = CreateVictim( scene, new Vector3( 60, 0, 0 ) );
var rdGo = scene.CreateObject();
var rd = rdGo.Components.Create( false );
rd.Radius = 200f;
rd.DamageAmount = 50f;
rd.Enabled = true;
Assert.AreEqual( 1, counter.Events, "an unoccluded target should take damage" );
Assert.IsTrue( counter.LastAmount > 0f );
}
///
/// FireDamage burns every IDamageable under the root object on a fixed interval:
/// each event is DamagePerSecond * 0.2 damage, tagged fire and burn.
///
[TestMethod]
public void FireDamageBurnsRootPeriodically()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var root = scene.CreateObject();
var counter = root.Components.Create();
var fireGo = scene.CreateObject();
fireGo.Parent = root;
fireGo.LocalPosition = new Vector3( 5, 0, 0 );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
fireGo.Components.Create();
}
for ( int i = 0; i < 20; i++ ) scene.GameTick();
Assert.IsTrue( counter.Events >= 4, $"the fire should burn repeatedly: {counter.Events}" );
Assert.IsTrue( counter.Events <= 12, $"the burn is limited to the damage interval: {counter.Events}" );
Assert.AreEqual( 4f, counter.LastAmount, 0.01f, "each event deals DamagePerSecond * interval" );
Assert.IsTrue( counter.Last.Tags.Has( "fire" ) );
Assert.IsTrue( counter.Last.Tags.Has( "burn" ) );
Assert.IsTrue( counter.Last.Origin.AlmostEqual( new Vector3( 5, 0, 0 ) ) );
}
///
/// TriggerHurt and RadiusDamage keep their configured values through a
/// serialization round trip, and FireDamage survives as a component.
///
[TestMethod]
public void DamageComponentSerialization()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var hurt = go.Components.Create();
hurt.Damage = 25f;
hurt.Rate = 2.5f;
hurt.Include.Add( "victim" );
var rd = go.Components.Create( false );
rd.Radius = 99f;
rd.DamageAmount = 12f;
rd.PhysicsForceScale = 3f;
rd.DamageOnEnabled = false;
rd.Occlusion = false;
go.Components.Create( false );
var copy = GameComponentTestUtils.RoundTrip( go );
var hurt2 = copy.Components.Get( true );
Assert.IsNotNull( hurt2 );
Assert.AreEqual( 25f, hurt2.Damage, 0.01f );
Assert.AreEqual( 2.5f, hurt2.Rate, 0.01f );
Assert.IsTrue( hurt2.Include.Has( "victim" ) );
var rd2 = copy.Components.Get( true );
Assert.IsNotNull( rd2 );
Assert.AreEqual( 99f, rd2.Radius, 0.01f );
Assert.AreEqual( 12f, rd2.DamageAmount, 0.01f );
Assert.AreEqual( 3f, rd2.PhysicsForceScale, 0.01f );
Assert.IsFalse( rd2.DamageOnEnabled );
Assert.IsFalse( rd2.Occlusion );
Assert.IsNotNull( copy.Components.Get( true ) );
}
}
///
/// Pins the mapping logic components: the Door state machine (rotating and sliding,
/// locking, auto close, linked doors, pressing) and the Button state machine
/// (toggle, auto reset, immediate, continuous and movement animation).
///
[TestClass]
public class MapLogicTest
{
Connection _previousLocalConnection;
NetworkSystem _previousNetworkSystem;
///
/// Pins Connection.Local to a host connection and clears Networking.System so the
/// host-gated entry points (Door.Toggle is [Rpc.Host], and the door/button RPCs
/// route through Rpc dispatch) execute locally instead of being forwarded or
/// silently dropped when another test in the assembly has leaked a non-host
/// client connection. Same idiom as ChairTests.cs.
///
[TestInitialize]
public void PinHostNetworkingState()
{
_previousLocalConnection = Connection.Local;
_previousNetworkSystem = Networking.System;
Connection.Local = new TestConnection( Guid.NewGuid(), isHost: true );
Networking.System = null;
}
///
/// Restores whatever global networking state existed before the test, leaving the
/// rest of the assembly exactly as it was.
///
[TestCleanup]
public void RestoreNetworkingState()
{
Connection.Local = _previousLocalConnection;
Networking.System = _previousNetworkSystem;
}
///
/// Creates a door on a fresh GameObject and ticks once so OnStart captures the
/// start transform and settles the initial state.
///
static (GameObject go, Door door) CreateDoor( Scene scene )
{
var go = scene.CreateObject();
var door = go.Components.Create();
return (go, door);
}
///
/// A rotating door starts Closed after OnStart (before that the state defaults to
/// Open - the enum's zero value), opens through the Opening state to a yaw of
/// TargetAngle, and closes back through Closing to its start rotation.
///
[TestMethod]
public void DoorRotatingOpenCloseStateMachine()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (go, door) = CreateDoor( scene );
// Before OnStart the state is the enum default - Open
Assert.AreEqual( Door.DoorState.Open, door.State );
scene.GameTick();
Assert.AreEqual( Door.DoorState.Closed, door.State, "the door starts closed" );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Open();
}
Assert.AreEqual( Door.DoorState.Opening, door.State, "opening starts synchronously" );
// Part way through the animation the door has rotated but isn't done
for ( int i = 0; i < 3; i++ ) scene.GameTick();
Assert.AreEqual( Door.DoorState.Opening, door.State );
var midYaw = MathF.Abs( go.WorldRotation.Angles().yaw );
Assert.IsTrue( midYaw > 1f && midYaw < 89f, $"the door should be mid swing: {midYaw}" );
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 );
Assert.AreEqual( Door.DoorState.Open, door.State );
Assert.AreEqual( 90f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Close();
}
Assert.AreEqual( Door.DoorState.Closing, door.State );
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 );
Assert.AreEqual( Door.DoorState.Closed, door.State );
Assert.AreEqual( 0f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f );
}
///
/// A sliding door moves to its SlideOffset when open and back when closed, at
/// Speed units per second.
///
[TestMethod]
public void DoorSlidingMovesBySlideOffset()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (go, door) = CreateDoor( scene );
door.Mode = Door.DoorMode.Sliding;
door.SlideOffset = new Vector3( 0, 0, 50 );
door.Speed = 100f;
scene.GameTick();
Assert.AreEqual( Door.DoorState.Closed, door.State );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Open();
}
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 );
Assert.AreEqual( Door.DoorState.Open, door.State );
Assert.AreEqual( 50f, go.LocalPosition.z, 1f, $"{go.LocalPosition}" );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Close();
}
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 );
Assert.AreEqual( 0f, go.LocalPosition.z, 1f, $"{go.LocalPosition}" );
}
///
/// A locked door refuses to open until unlocked.
///
[TestMethod]
public void DoorLockedRefusesToOpen()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, door) = CreateDoor( scene );
door.IsLocked = true;
scene.GameTick();
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Open();
}
Assert.AreEqual( Door.DoorState.Closed, door.State, "a locked door stays closed" );
for ( int i = 0; i < 5; i++ ) scene.GameTick();
Assert.AreEqual( Door.DoorState.Closed, door.State );
door.IsLocked = false;
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Open();
}
Assert.AreEqual( Door.DoorState.Opening, door.State, "an unlocked door opens" );
}
///
/// With AutoClose enabled the door closes itself after the delay.
///
[TestMethod]
public void DoorAutoClosesAfterDelay()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, door) = CreateDoor( scene );
door.AutoClose = true;
door.AutoCloseDelay = 0.2f;
scene.GameTick();
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Open();
}
Assert.AreEqual( Door.DoorState.Opening, door.State );
// The door swings fully open, waits out the delay and closes itself again.
// The open state can be passed within a single tick, so wait for the round trip.
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 80 );
Assert.AreEqual( Door.DoorState.Closed, door.State, "the door should close itself" );
}
///
/// StartOpen places the door in the open pose at start, and it can be closed
/// back to its captured start transform from there.
///
[TestMethod]
public void DoorStartOpenBeginsOpen()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (go, door) = CreateDoor( scene );
door.StartOpen = true;
scene.GameTick();
Assert.AreEqual( Door.DoorState.Open, door.State );
Assert.AreEqual( 90f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
door.Close();
}
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Closed, 40 );
Assert.AreEqual( Door.DoorState.Closed, door.State );
Assert.AreEqual( 0f, MathF.Abs( go.WorldRotation.Angles().yaw ), 1f );
}
///
/// A linked door back-links itself on start and opens and closes together with
/// its partner.
///
[TestMethod]
public void DoorLinkedDoorFollows()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, doorA) = CreateDoor( scene );
var (_, doorB) = CreateDoor( scene );
doorA.LinkedDoor = doorB;
scene.GameTick();
Assert.AreEqual( doorA, doorB.LinkedDoor, "the linked door should back-link on start" );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
doorA.Open();
}
Assert.AreEqual( Door.DoorState.Opening, doorA.State );
Assert.AreEqual( Door.DoorState.Opening, doorB.State, "the linked door opens too" );
GameComponentTestUtils.TickUntil( scene,
() => doorA.State == Door.DoorState.Open && doorB.State == Door.DoorState.Open, 40 );
Assert.AreEqual( Door.DoorState.Open, doorA.State );
Assert.AreEqual( Door.DoorState.Open, doorB.State );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
doorA.Close();
}
Assert.AreEqual( Door.DoorState.Closing, doorB.State, "the linked door closes too" );
}
///
/// Pressing the door through IPressable toggles it. While the door is animating
/// CanPress reports false.
///
[TestMethod]
public void DoorPressToggles()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var (_, door) = CreateDoor( scene );
var presserGo = scene.CreateObject();
presserGo.WorldPosition = new Vector3( 50, 0, 0 );
var presser = presserGo.Components.Create();
var pressEvent = new Component.IPressable.Event( presser );
scene.GameTick();
var pressable = (Component.IPressable)door;
Assert.IsTrue( pressable.CanPress( pressEvent ), "a resting door can be pressed" );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
Assert.IsTrue( pressable.Press( pressEvent ) );
}
Assert.AreEqual( Door.DoorState.Opening, door.State );
Assert.IsFalse( pressable.CanPress( pressEvent ), "an animating door can't be pressed" );
GameComponentTestUtils.TickUntil( scene, () => door.State == Door.DoorState.Open, 40 );
Assert.IsTrue( pressable.CanPress( pressEvent ) );
using ( GameComponentTestUtils.PushSceneClock( scene ) )
{
pressable.Press( pressEvent );
}
Assert.AreEqual( Door.DoorState.Closing, door.State, "pressing an open door closes it" );
}
///
/// A toggle button with AutoReset turns itself back off after the reset time.
/// TurnOff during the on-animation is ignored.
///
[TestMethod]
public void ButtonToggleAutoResets()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var button = go.Components.Create