using Godot; using System; using System.Threading.Tasks; // Headless regression for the in-VR menu (MenuScreen + MainMenu), no XR: drives simulated // controller rays at known controls and asserts the Godot UI reacted. Run: // godot-mono --headless --fixed-fps 90 --path . scenes/menu_spike.tscn // Must print "MENU: overall PASS". public partial class MenuSpike : Node3D { private partial class CalibrationStub : Node { public int Calls; public void Recalibrate() => Calls++; } private MenuScreen _screen; private MainMenu _menu; private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2]; private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents, _hapticTicks; private bool _allPass = true; public override void _Ready() { _screen = new MenuScreen(); AddChild(_screen); // Arbitrary, non-axis-aligned pose to exercise the plane math. _screen.GlobalTransform = new Transform3D( Basis.FromEuler(new Vector3(Mathf.DegToRad(-15f), Mathf.DegToRad(30f), 0f)), new Vector3(0.3f, 1.3f, -0.5f)); _menu = new MainMenu(); _screen.SetContent(_menu); _screen.SetActive(true); _screen.PointerPressed += (_, _) => _pressedEvents++; _screen.Haptic += (_, _, _) => _hapticTicks++; _menu.ReturnToSpawnRequested += () => _spawn++; _menu.ResetPropsRequested += () => _reset++; _menu.PingRequested += () => _ping++; _menu.CloseRequested += () => _close++; _menu.Clicked += () => _clicks++; GetTree().CreateTimer(30.0).Timeout += () => { GD.Print("MENU: timeout — overall FAIL"); GetTree().Quit(1); }; Run(); } private void Check(string name, bool ok, string detail = "") { GD.Print($"MENU: {name} {detail} → {(ok ? "PASS" : "FAIL")}"); _allPass &= ok; } private async Task Frame(int count = 1) { for (int i = 0; i < count; i++) { _screen.UpdatePointers(_inputs, 1.0 / 90.0); await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame); } } private Vector2 Center(Control c) => c.GetGlobalRect().GetCenter(); private Vector3 Normal => _screen.GlobalBasis.Z.Normalized(); private void SetHand(int hand, Vector3 origin, Vector3 dir, bool trigger = false, Vector2 stick = default) => _inputs[hand] = new MenuScreen.PointerInput { Valid = true, Origin = origin, Direction = dir.Normalized(), Trigger = trigger, Stick = stick }; private void ClearHand(int hand) => _inputs[hand] = default; // Stylus straight at the panel: tip depth d in front of the pixel (negative = pushed through). private void TipAt(int hand, Vector2 pixel, float depth) { Vector3 p = _screen.PixelToGlobal(pixel); SetHand(hand, p + Normal * (MenuScreen.TipLength + depth), -Normal); } private async Task TouchPress(int hand, Vector2 pixel) { for (float d = 0.10f; d > -0.012f; d -= 0.004f) { TipAt(hand, pixel, d); await Frame(); } for (float d = -0.012f; d < 0.08f; d += 0.006f) { TipAt(hand, pixel, d); await Frame(); } ClearHand(hand); await Frame(3); } // Touch down at `from`, slide to `to` while in contact, lift. fling = a fast 4-frame slide. private async Task TouchDrag(int hand, Vector2 from, Vector2 to, bool fling = false) { for (float d = 0.06f; d > -0.004f; d -= 0.004f) { TipAt(hand, from, d); await Frame(); } // A fling lifts while still moving (as a real flick does); a slow drag stops, then lifts. int steps = fling ? 8 : 30; for (int i = 1; i <= steps; i++) { float lift = fling ? Mathf.Max(0f, i - 4) * 0.008f : 0f; TipAt(hand, from.Lerp(to, i / (float)steps), -0.004f + lift); await Frame(); } float start = fling ? -0.004f + (steps - 4) * 0.008f : -0.004f; for (float d = start; d < 0.08f; d += 0.006f) { TipAt(hand, to, d); await Frame(); } ClearHand(hand); await Frame(3); } // Laser from 0.8 m, off-axis, aimed at the pixel. private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default) { Vector3 p = _screen.PixelToGlobal(pixel); Vector3 origin = p + Normal * 0.7f + _screen.GlobalBasis.X * 0.25f + Vector3.Up * 0.2f; SetHand(hand, origin, p - origin, trigger, stick); } private async Task LaserClick(int hand, Vector2 pixel) { LaserAt(hand, pixel, false); await Frame(4); LaserAt(hand, pixel, true); await Frame(4); LaserAt(hand, pixel, false); await Frame(4); ClearHand(hand); await Frame(3); } private Vector2 TabCenter(int index) { TabBar bar = _menu.Tabs.GetTabBar(); Rect2 r = bar.GetTabRect(index); return bar.GetGlobalRect().Position + r.GetCenter(); } private async void Run() { await Frame(10); // layout // Mapping sanity: pixel → world → local → pixel round trip. Vector2 probe = new(123f, 456f); Vector2 back = _screen.LocalToPixel(_screen.GlobalTransform.AffineInverse() * _screen.PixelToGlobal(probe)); Check("pixel mapping round trip", back.DistanceTo(probe) < 0.01f, $"({back})"); // 1) Touch path, with the other hand idling as a laser on the panel (arbitration: // touch beats laser). Vector2 spawnPx = Center(_menu.ReturnToSpawnButton); LaserAt(0, new Vector2(800, 600), false); await Frame(3); Check("idle laser is laser mode", _screen.GetMode(0) == MenuScreen.PointerMode.Laser); bool sawHover = false, sawTouch = false; for (float d = 0.10f; d > -0.012f; d -= 0.004f) { TipAt(1, spawnPx, d); await Frame(); sawTouch |= _screen.GetMode(1) == MenuScreen.PointerMode.Touch; sawHover |= _menu.ReturnToSpawnButton.IsHovered(); } bool pressedWhileIn = _screen.IsPressing(1); for (float d = -0.012f; d < 0.08f; d += 0.006f) { TipAt(1, spawnPx, d); await Frame(); } ClearHand(0); ClearHand(1); await Frame(3); Check("touch: tip mode + hover", sawTouch && sawHover, $"(touch={sawTouch}, hover={sawHover})"); Check("touch: contact holds press", pressedWhileIn); Check("touch: Return to spawn fired once", _spawn == 1, $"(count={_spawn}, pressedEvents={_pressedEvents})"); // 2) Tip sweeping in sideways already behind the surface must NOT press. int before = _spawn; Vector2 offPanel = new(-60f, spawnPx.Y); for (int i = 0; i <= 30; i++) { Vector2 px = offPanel.Lerp(spawnPx, i / 30f); Vector3 p = _screen.GlobalTransform * new Vector3( (px.X / _screen.SizePixels.X - 0.5f) * _screen.SizeMeters.X, (0.5f - px.Y / _screen.SizePixels.Y) * _screen.SizeMeters.Y, 0f); SetHand(1, p + Normal * (MenuScreen.TipLength - 0.01f), -Normal); await Frame(); } TipAt(1, spawnPx, 0.08f); await Frame(3); ClearHand(1); await Frame(3); Check("touch: side entry behind surface does not press", _spawn == before, $"(count={_spawn})"); // 3) Laser + trigger: switch to the World tab, then Reset props. await LaserClick(0, TabCenter(1)); Check("laser: World tab selected", _menu.Tabs.CurrentTab == 1, $"(tab={_menu.Tabs.CurrentTab})"); await Frame(2); Vector2 resetPx = Center(_menu.ResetPropsButton); LaserAt(0, resetPx, false); await Frame(2); bool laserDist = _screen.TryGetLaserDistance(0, out float dist) && dist > 0.5f && dist < 1.0f; Check("laser: distance reported", laserDist, $"({dist:F3} m)"); await LaserClick(0, resetPx); Check("laser: Reset props fired once", _reset == 1, $"(count={_reset})"); // 4) Trigger already held when the laser arrives must not click. LaserAt(0, new Vector2(-200, -200), true); // off panel await Frame(3); LaserAt(0, Center(_menu.PingButton), true); await Frame(5); LaserAt(0, Center(_menu.PingButton), false); await Frame(3); ClearHand(0); await Frame(2); Check("laser: held trigger arriving does not click", _ping == 0, $"(count={_ping})"); await LaserClick(0, Center(_menu.PingButton)); Check("laser: Ping fired once", _ping == 1, $"(count={_ping})"); // 5) Touch a tab, then drag the slider with the laser (trigger held). await TouchPress(1, TabCenter(2)); Check("touch: System tab selected", _menu.Tabs.CurrentTab == 2, $"(tab={_menu.Tabs.CurrentTab})"); await Frame(2); Rect2 sr = _menu.ClickVolumeSlider.GetGlobalRect(); double v0 = _menu.ClickVolumeSlider.Value; Vector2 grab = new(sr.Position.X + sr.Size.X * (float)(v0 / 100.0), sr.GetCenter().Y); LaserAt(0, grab, false); await Frame(3); LaserAt(0, grab, true); await Frame(3); for (int i = 1; i <= 20; i++) { LaserAt(0, grab.Lerp(new Vector2(sr.Position.X + 4f, grab.Y), i / 20f), true); await Frame(); } LaserAt(0, new Vector2(sr.Position.X + 4f, grab.Y), false); await Frame(3); ClearHand(0); await Frame(2); double v1 = _menu.ClickVolumeSlider.Value; Check("laser: slider drag", v1 < v0 - 30, $"({v0} → {v1})"); // 6) Thumbstick scrolls the log while hovering it. for (int i = 0; i < 60; i++) _menu.Log($"log line {i}"); await Frame(5); Vector2 logPx = Center(_menu.LogScroll); LaserAt(0, logPx, false, new Vector2(0, -1)); await Frame(30); int scrolled = _menu.LogScroll.ScrollVertical; LaserAt(0, logPx, false, new Vector2(0, 1)); await Frame(60); int scrolledBack = _menu.LogScroll.ScrollVertical; ClearHand(0); await Frame(2); Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})"); // 6b) Touch swipe vs tap inside the scrollable log. Button logButton = new() { Text = "in-log button", CustomMinimumSize = new Vector2(0, 48) }; int logButtonPresses = 0; logButton.Pressed += () => logButtonPresses++; _menu.LogScroll.GetChild(0).AddChild(logButton); _menu.LogScroll.GetChild(0).MoveChild(logButton, 0); _menu.LogScroll.ScrollVertical = 0; await Frame(5); int clicksBefore = _clicks; await TouchDrag(1, logPx, logPx + new Vector2(0, -150)); int swiped = _menu.LogScroll.ScrollVertical; Check("touch: swipe up scrolls the log", swiped >= 120, $"(scroll 0 → {swiped})"); Check("touch: swipe sends no click", _clicks == clicksBefore && logButtonPresses == 0, $"(clicks +{_clicks - clicksBefore})"); await TouchDrag(1, logPx, logPx + new Vector2(0, 80), fling: true); await Frame(60); int flung = _menu.LogScroll.ScrollVertical; Check("touch: fast swipe down flings past the finger", flung < swiped - 60, $"({swiped} → {flung})"); _menu.LogScroll.ScrollVertical = 0; await Frame(5); Vector2 btnPx = Center(logButton); await TouchDrag(1, btnPx, btnPx + new Vector2(6, 5)); Check("touch: small-jitter tap on in-log button clicks", logButtonPresses == 1 && _menu.LogScroll.ScrollVertical == 0, $"(presses={logButtonPresses}, scroll={_menu.LogScroll.ScrollVertical})"); VScrollBar vbar = _menu.LogScroll.GetVScrollBar(); Rect2 br = vbar.GetGlobalRect(); await TouchDrag(1, new Vector2(br.GetCenter().X, br.Position.Y + 10), new Vector2(br.GetCenter().X, br.End.Y - 10)); Check("touch: scrollbar still drags directly", _menu.LogScroll.ScrollVertical > 100, $"(scroll={_menu.LogScroll.ScrollVertical})"); // 7) Recalibrate: disabled without a calibration node, enabled + calls it when present. await TouchPress(1, TabCenter(0)); Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled); await TouchPress(1, Center(_menu.RecalibrateButton)); CalibrationStub stub = new(); AddChild(stub); stub.AddToGroup(MainMenu.CalibrationGroup); await Frame(40); // refresh runs at 4 Hz Check("Recalibrate enabled with group node", !_menu.RecalibrateButton.Disabled); await TouchPress(1, Center(_menu.RecalibrateButton)); Check("touch: Recalibrate called once", stub.Calls == 1, $"(calls={stub.Calls})"); // 8) Header Close via touch. await TouchPress(1, Center(_menu.HeaderCloseButton)); Check("touch: Close fired once", _close == 1, $"(count={_close})"); Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})"); Check("haptic ticks fired (hover/release/detents)", _hapticTicks >= 30, $"(ticks={_hapticTicks})"); GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}"); GetTree().Quit(_allPass ? 0 : 1); } }