315 lines
13 KiB
C#
315 lines
13 KiB
C#
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);
|
|
}
|
|
}
|