using Sandbox.UI; namespace SceneTests.Components; /// /// A minimal PanelComponent used to observe the panel lifecycle and the razor /// tree build callbacks. The build hash is exposed so tests can force rebuilds. /// public sealed class HudProbePanel : PanelComponent { /// /// How many times OnTreeFirstBuilt has fired. /// public int FirstBuilds { get; private set; } /// /// How many times OnTreeBuilt has fired. /// public int Builds { get; private set; } /// /// Value returned from BuildHash - changing this should trigger a rebuild. /// public int HashValue { get; set; } /// /// Counts the first tree build. /// protected override void OnTreeFirstBuilt() { FirstBuilds++; } /// /// Counts every tree build. /// protected override void OnTreeBuilt() { Builds++; } /// /// Feeds into the render hash so tests can dirty the tree. /// protected override int BuildHash() => HashValue; } [TestClass] public class ScreenPanelTest { /// /// Creating a ScreenPanel immediately creates its internal GameRootPanel on awake. /// The root is rendered manually (not by the normal panel system), is not a world /// panel, is linked back to the owning GameObject and starts displayed (Flex) /// with the default property values pushed into the panel state. /// [TestMethod] public void EnableCreatesRootPanel() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = sp.GetPanel(); Assert.IsNotNull( root, "Root panel should be created on awake" ); Assert.IsInstanceOfType( root, typeof( GameRootPanel ) ); Assert.IsTrue( root.IsValid ); Assert.IsTrue( ((RootPanel)root).RenderedManually ); Assert.IsFalse( ((RootPanel)root).IsWorldPanel ); Assert.AreEqual( go, root.GameObject ); Assert.AreEqual( DisplayMode.Flex, root.Style.Display ); // defaults pushed by the OnUpdate call in OnAwake Assert.AreEqual( 100, root.Style.ZIndex ); Assert.AreEqual( 1.0f, root.Style.Opacity ); go.Destroy(); scene.ProcessDeletes(); } /// /// A PanelComponent on the same GameObject as a ScreenPanel parents its panel /// to the screen panel's root, because IRootPanelComponent lookup on self wins. /// [TestMethod] public void PanelComponentParentsToRootOnSameObject() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var probe = go.Components.Create(); Assert.IsNotNull( probe.Panel, "PanelComponent should create its panel on enable" ); Assert.AreEqual( sp.GetPanel(), probe.Panel.Parent ); go.Destroy(); scene.ProcessDeletes(); } /// /// A PanelComponent on a child GameObject finds the ScreenPanel in its ancestors /// and parents its panel to that root panel. /// [TestMethod] public void PanelComponentParentsToAncestorRoot() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var child = scene.CreateObject(); child.Parent = go; var probe = child.Components.Create(); Assert.IsNotNull( probe.Panel ); Assert.AreEqual( sp.GetPanel(), probe.Panel.Parent ); go.Destroy(); scene.ProcessDeletes(); } /// /// When a PanelComponent exists before any root, its panel is orphaned. Adding /// a ScreenPanel afterwards adopts it - OnEnabled walks self and descendants /// calling EnsureParentPanel, which re-parents the orphan to the new root. /// [TestMethod] public void RootPanelAdoptsExistingPanelComponents() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var probe = go.Components.Create(); Assert.IsNotNull( probe.Panel ); Assert.IsNull( probe.Panel.Parent, "Panel should be orphaned without a root panel" ); var sp = go.Components.Create(); Assert.AreEqual( sp.GetPanel(), probe.Panel.Parent, "ScreenPanel enable should adopt orphaned panels" ); go.Destroy(); scene.ProcessDeletes(); } /// /// Plain panels can be manually parented to the screen panel's root and show /// up in its child list. /// [TestMethod] public void ManualChildPanelCanBeAdded() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var child = new Panel { Parent = sp.GetPanel() }; Assert.AreEqual( sp.GetPanel(), child.Parent ); Assert.IsTrue( sp.GetPanel().Children.Contains( child ) ); go.Destroy(); scene.ProcessDeletes(); } /// /// Opacity, Scale, AutoScreenScale, ScaleStrategy and ZIndex are copied into the /// root panel state by OnUpdate, so changes become visible after a game tick. /// [TestMethod] public void PropertyChangesPropagateOnUpdate() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = (GameRootPanel)sp.GetPanel(); sp.Opacity = 0.25f; sp.Scale = 2.0f; sp.AutoScreenScale = false; sp.ScaleStrategy = ScreenPanel.AutoScale.FollowDesktopScaling; sp.ZIndex = 7; scene.GameTick(); Assert.AreEqual( 0.25f, root.Style.Opacity ); Assert.AreEqual( 7, root.Style.ZIndex ); Assert.AreEqual( 2.0f, root.ManualScale ); Assert.IsFalse( root.AutoScale ); Assert.AreEqual( ScreenPanel.AutoScale.FollowDesktopScaling, root.ScaleStrategy ); go.Destroy(); scene.ProcessDeletes(); } /// /// With AutoScreenScale off the root panel uses the manual Scale verbatim when /// the layout pass recomputes scaling from the screen rect. /// [TestMethod] public void ManualScaleAppliedDuringLayout() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = (GameRootPanel)sp.GetPanel(); sp.AutoScreenScale = false; sp.Scale = 3.0f; scene.GameTick(); root.PreLayout( new Rect( 0, 0, 1920, 1080 ) ); Assert.AreEqual( 3.0f, root.Scale ); // Screen size shouldn't matter for manual scale root.PreLayout( new Rect( 0, 0, 960, 540 ) ); Assert.AreEqual( 3.0f, root.Scale ); go.Destroy(); scene.ProcessDeletes(); } /// /// The default ConsistentHeight auto scale strategy scales relative to a 1080p /// screen height and multiplies in the manual scale. /// [TestMethod] public void AutoScaleConsistentHeightTracksScreenHeight() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = (GameRootPanel)sp.GetPanel(); Assert.IsTrue( sp.AutoScreenScale, "AutoScreenScale should default on" ); Assert.AreEqual( ScreenPanel.AutoScale.ConsistentHeight, sp.ScaleStrategy ); root.PreLayout( new Rect( 0, 0, 1920, 1080 ) ); Assert.AreEqual( 1.0f, root.Scale, 0.001f ); root.PreLayout( new Rect( 0, 0, 960, 540 ) ); Assert.AreEqual( 0.5f, root.Scale, 0.001f ); // manual scale multiplies into the auto scale sp.Scale = 2.0f; scene.GameTick(); root.PreLayout( new Rect( 0, 0, 960, 540 ) ); Assert.AreEqual( 1.0f, root.Scale, 0.001f ); go.Destroy(); scene.ProcessDeletes(); } /// /// Disabling the component hides the root panel by switching its display mode /// to None, and re-enabling restores Flex on the same root panel instance. /// [TestMethod] public void DisableHidesRootPanel() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = sp.GetPanel(); Assert.AreEqual( DisplayMode.Flex, root.Style.Display ); sp.Enabled = false; Assert.AreEqual( DisplayMode.None, root.Style.Display ); Assert.IsTrue( root.IsValid, "Disable should hide, not delete, the root panel" ); sp.Enabled = true; Assert.AreEqual( DisplayMode.Flex, root.Style.Display ); Assert.AreSame( root, sp.GetPanel(), "Root panel should survive a disable/enable cycle" ); go.Destroy(); scene.ProcessDeletes(); } /// /// Destroying the GameObject deletes the root panel immediately - the panel /// becomes invalid and the component reports a null panel. /// [TestMethod] public void DestroyDeletesRootPanel() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var root = sp.GetPanel(); Assert.IsTrue( root.IsValid ); go.Destroy(); scene.ProcessDeletes(); Assert.IsNull( sp.GetPanel(), "Destroy should null the root panel" ); Assert.IsFalse( root.IsValid, "Destroy should delete the root panel immediately" ); } /// /// Each ScreenPanel pushes its ZIndex into its root panel's style. The layering /// itself - CameraComponent's UI render pass ordering the scene's ScreenPanels /// by ZIndex (the OrderBy in CameraComponent.cs) so the lower index renders /// first (underneath) - happens during rendering and isn't assertable headless. /// [TestMethod] public void TwoScreenPanelsLayerByZIndex() { var scene = new Scene(); using var sceneScope = scene.Push(); var goA = scene.CreateObject(); var spA = goA.Components.Create(); spA.ZIndex = 10; var goB = scene.CreateObject(); var spB = goB.Components.Create(); spB.ZIndex = 5; scene.GameTick(); Assert.AreEqual( 10, spA.GetPanel().Style.ZIndex ); Assert.AreEqual( 5, spB.GetPanel().Style.ZIndex ); goA.Destroy(); goB.Destroy(); scene.ProcessDeletes(); } /// /// ScreenPanel properties survive a serialize / deserialize round trip of the /// owning GameObject. /// [TestMethod] public void SerializationRoundTrip() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); sp.Opacity = 0.5f; sp.Scale = 2.0f; sp.AutoScreenScale = false; sp.ScaleStrategy = ScreenPanel.AutoScale.FollowDesktopScaling; sp.ZIndex = 42; var json = go.Serialize().ToJsonString(); var jsonObject = Json.ParseToJsonObject( json ); Assert.IsNotNull( jsonObject ); SceneUtility.MakeIdGuidsUnique( jsonObject ); var copy = new GameObject( false ); copy.Deserialize( jsonObject ); copy.Enabled = true; var sp2 = copy.GetComponent(); Assert.IsNotNull( sp2 ); Assert.AreEqual( 0.5f, sp2.Opacity ); Assert.AreEqual( 2.0f, sp2.Scale ); Assert.IsFalse( sp2.AutoScreenScale ); Assert.AreEqual( ScreenPanel.AutoScale.FollowDesktopScaling, sp2.ScaleStrategy ); Assert.AreEqual( 42, sp2.ZIndex ); go.Destroy(); copy.Destroy(); scene.ProcessDeletes(); } } [TestClass] public class PanelComponentLifecycleTest { /// /// Enabling a PanelComponent creates its panel with the element name set to the /// lowercase component type name, linked to the GameObject and parented to the /// root panel found on the same object. /// [TestMethod] public void PanelCreatedOnEnable() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var probe = go.Components.Create(); Assert.IsNotNull( probe.Panel ); Assert.IsTrue( probe.Panel.IsValid ); Assert.AreEqual( "hudprobepanel", probe.Panel.ElementName ); Assert.AreEqual( go, probe.Panel.GameObject ); Assert.AreEqual( sp.GetPanel(), probe.Panel.Parent ); go.Destroy(); scene.ProcessDeletes(); } /// /// Disabling a PanelComponent deletes its panel (deferred) and nulls the Panel /// property. Re-enabling creates a brand new panel which re-parents itself /// to the root. /// [TestMethod] public void DisableDestroysPanel() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var probe = go.Components.Create(); var firstPanel = probe.Panel; Assert.IsNotNull( firstPanel ); probe.Enabled = false; Assert.IsNull( probe.Panel, "Panel should be nulled on disable" ); Assert.IsTrue( firstPanel.IsDeleting, "Old panel should be marked for deletion" ); probe.Enabled = true; Assert.IsNotNull( probe.Panel ); Assert.AreNotSame( firstPanel, probe.Panel, "Re-enable should create a fresh panel" ); Assert.AreEqual( sp.GetPanel(), probe.Panel.Parent ); go.Destroy(); scene.ProcessDeletes(); } /// /// The razor tree build lifecycle: the first layout pass builds the tree /// (OnTreeFirstBuilt fires exactly once), repeated passes settle and stop /// rebuilding while the build hash is stable, a hash change triggers exactly /// one rebuild and so does an explicit StateHasChanged call. /// [TestMethod] public void TreeBuildAndRebuild() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); var sp = go.Components.Create(); var probe = go.Components.Create(); var root = (RootPanel)sp.GetPanel(); // First layout pass builds the tree root.Layout(); Assert.AreEqual( 1, probe.FirstBuilds ); Assert.IsTrue( probe.Builds >= 1 ); // Let the build hash prime, then verify the tree settles root.Layout(); root.Layout(); var stable = probe.Builds; root.Layout(); Assert.AreEqual( stable, probe.Builds, "Tree should not rebuild while the hash is stable" ); // Changing the build hash forces a rebuild probe.HashValue = 7; root.Layout(); Assert.AreEqual( stable + 1, probe.Builds, "Hash change should rebuild the tree" ); // StateHasChanged dirties the tree manually probe.StateHasChanged(); root.Layout(); Assert.AreEqual( stable + 2, probe.Builds, "StateHasChanged should rebuild the tree" ); Assert.AreEqual( 1, probe.FirstBuilds, "First-build callback should only fire once" ); go.Destroy(); scene.ProcessDeletes(); } /// /// The class accessors on PanelComponent forward to the underlying panel: /// AddClass / HasClass / SetClass / RemoveClass manipulate panel classes. /// [TestMethod] public void ClassAccessorsForwardToPanel() { var scene = new Scene(); using var sceneScope = scene.Push(); var go = scene.CreateObject(); go.Components.Create(); var probe = go.Components.Create(); Assert.IsFalse( probe.HasClass( "open" ) ); probe.AddClass( "open" ); Assert.IsTrue( probe.HasClass( "open" ) ); Assert.IsTrue( probe.Panel.HasClass( "open" ) ); probe.SetClass( "open", false ); Assert.IsFalse( probe.HasClass( "open" ) ); probe.AddClass( "closed" ); probe.RemoveClass( "closed" ); Assert.IsFalse( probe.HasClass( "closed" ) ); go.Destroy(); scene.ProcessDeletes(); } }