using System.Collections.Generic;
namespace SceneTests.Components;
///
/// Tests for the core Component behaviour in Component.cs: shortcut accessors,
/// destruction, the OnComponent* action callbacks, the Enabled/Active split,
/// tag change notifications and Reset().
///
[TestClass]
[DoNotParallelize]
public class ComponentLifecycleTest : SceneTest
{
///
/// Scene, Transform, Components and Tags on a component are shortcuts to the
/// owning GameObject's members - the very same instances, not copies.
///
[TestMethod]
public void ShortcutAccessorsPointAtGameObject()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
Assert.AreSame( go, comp.GameObject );
Assert.AreSame( scene, comp.Scene );
Assert.AreSame( go.Transform, comp.Transform );
Assert.AreSame( go.Components, comp.Components );
go.Tags.Add( "probe" );
Assert.IsTrue( comp.Tags.Has( "probe" ) );
}
///
/// OnTagsChanged is dispatched to components when a tag is added to or
/// removed from the owning GameObject.
///
[TestMethod]
public void OnTagsChangedFiresOnTagChange()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
var baseline = comp.TagsChangedCalls;
go.Tags.Add( "alpha" );
Assert.AreEqual( baseline + 1, comp.TagsChangedCalls );
go.Tags.Remove( "alpha" );
Assert.AreEqual( baseline + 2, comp.TagsChangedCalls );
}
///
/// Destroying a component takes effect immediately (unlike GameObject
/// destruction): it is disabled then destroyed in that order, unlinked from
/// its GameObject, removed from the component list and becomes invalid.
/// The GameObject itself survives.
///
[TestMethod]
public void DestroyComponentTakesEffectImmediately()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
Assert.IsTrue( comp.IsValid );
comp.Destroy();
Assert.IsFalse( comp.IsValid );
Assert.IsNull( comp.GameObject );
Assert.IsNull( comp.Scene );
Assert.IsNull( comp.Components );
Assert.AreEqual( 1, comp.DisabledCalls );
Assert.AreEqual( 1, comp.DestroyCalls );
CollectionAssert.AreEqual( new List { "Enabled", "Disabled", "Destroy" }, comp.Order );
Assert.AreEqual( 0, go.Components.GetAll().Count() );
Assert.IsTrue( go.IsValid );
}
///
/// Destroying an already destroyed component is a safe no-op - the lifecycle
/// callbacks don't run a second time.
///
[TestMethod]
public void DestroyTwiceIsNoOp()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
comp.Destroy();
comp.Destroy();
Assert.AreEqual( 1, comp.DisabledCalls );
Assert.AreEqual( 1, comp.DestroyCalls );
}
///
/// DestroyGameObject destroys the whole owning GameObject, not just the
/// component. The object goes through the usual deferred delete, so it dies
/// on the next tick along with the component.
///
[TestMethod]
public void DestroyGameObjectDestroysOwner()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
comp.DestroyGameObject();
scene.GameTick();
Assert.IsFalse( go.IsValid );
Assert.IsFalse( comp.IsValid );
Assert.AreEqual( 1, comp.DestroyCalls );
}
///
/// The OnComponentEnabled / OnComponentDisabled / OnComponentDestroy action
/// properties are invoked alongside the corresponding lifecycle callbacks.
///
[TestMethod]
public void ActionCallbacksAreInvoked()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create( false );
int enabled = 0, disabled = 0, destroyed = 0;
comp.OnComponentEnabled = () => enabled++;
comp.OnComponentDisabled = () => disabled++;
comp.OnComponentDestroy = () => destroyed++;
comp.Enabled = true;
Assert.AreEqual( 1, enabled );
comp.Enabled = false;
Assert.AreEqual( 1, disabled );
comp.Destroy();
Assert.AreEqual( 1, destroyed );
Assert.AreEqual( 1, enabled );
Assert.AreEqual( 1, disabled );
}
///
/// Enabled is what the component wants; Active is what it actually is. A
/// component that is enabled on a disabled GameObject is not active, and
/// becomes active once the whole ancestor chain is enabled.
///
[TestMethod]
public void ActiveReflectsGameObjectHierarchy()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject( false );
var comp = go.Components.Create();
Assert.IsTrue( comp.Enabled );
Assert.IsFalse( comp.Active );
go.Enabled = true;
Assert.IsTrue( comp.Active );
comp.Enabled = false;
Assert.IsFalse( comp.Active );
Assert.IsTrue( go.Active );
}
///
/// Reset restores every [Property] member to its default: the value from
/// [DefaultValue] when present, otherwise the type default
/// (SerializedProperty.GetDefault). Initializer values without a
/// [DefaultValue] attribute are not preserved - the fixture's initializers
/// deliberately differ from both the attribute value and the type default
/// so each outcome is distinguishable.
///
[TestMethod]
public void ResetRestoresPropertyDefaults()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var go = scene.CreateObject();
var comp = go.Components.Create();
comp.Number = 99;
comp.Plain = 99;
comp.Reset();
// [DefaultValue( 7 )] wins - not the initializer (3), not the type default (0)
Assert.AreEqual( 7, comp.Number );
// No attribute - type default (0) wins, the initializer (5) is not preserved
Assert.AreEqual( 0, comp.Plain );
}
}
///
/// Component that records its lifecycle callbacks into counters and an ordered
/// log. Never asserts inside the callbacks - they run inside CallbackBatch
/// which swallows exceptions.
///
public class LifecycleProbeComponent : Component
{
public int EnabledCalls;
public int DisabledCalls;
public int DestroyCalls;
public int TagsChangedCalls;
public List Order = new();
///
/// Records the enable callback.
///
protected override void OnEnabled()
{
EnabledCalls++;
Order.Add( "Enabled" );
}
///
/// Records the disable callback.
///
protected override void OnDisabled()
{
DisabledCalls++;
Order.Add( "Disabled" );
}
///
/// Records the destroy callback.
///
protected override void OnDestroy()
{
DestroyCalls++;
Order.Add( "Destroy" );
}
///
/// Records tag change notifications.
///
protected override void OnTagsChanged()
{
TagsChangedCalls++;
}
}
///
/// Component with one property carrying a [DefaultValue] and one without, to
/// pin what Component.Reset() restores. The initializers intentionally differ
/// from the attribute value and the type default so the test can tell apart
/// "restored from [DefaultValue]", "restored from initializer" and "type default".
///
public class ResetDefaultsComponent : Component
{
[Property, DefaultValue( 7 )]
public int Number { get; set; } = 3;
[Property]
public int Plain { get; set; } = 5;
}