spaceship/tests/test_main_menu.gd
Loïc Wisniewski 137e4e528d Fix the web build: a wasm trap on integer % 0, and buttons that never fired
The web export reached the main menu and went no further. Two separate bugs,
one hiding the other.

BUTTONS NEVER FIRED. A browser on a phone delivers the touch DOWN and never
the release. IconButton emits `pressed` only on release, deliberately, so
that dragging off cancels - so on the web it never emitted at all, on every
button in the game. fire_on_press (default OS.has_feature("web")) completes
the press there instead, and does not latch, since nothing is coming to
unlatch it. The cost is drag-off-to-cancel, which every other platform keeps.

Measured on the device rather than guessed: an on-screen probe reported
`touch=1 mouse=1 held=y` after one tap - one down, one emulated mouse down,
the button latched through ordinary GUI routing, then nothing.

THE ENGINE THEN TRAPPED. With the button working, pressing Play loaded and
built Main.tscn and the whole engine stopped mid-frame, page still alive.
An error overlay in a custom HTML shell finally showed why:

    Uncaught RuntimeError: remainder by zero

An integer % 0. Wasm TRAPS on that; native builds silently tolerate it. So
the bug was equally present on Android and in all 100 tests, doing nothing
in both - which is why nothing runnable here could ever have caught it, and
why reasoning from frozen screenshots got nowhere for several rounds. Every
variable modulo is now guarded: the hangar grid's `i % columns`, planet
naming's `% names.size()`, and two in the map editor.

WHAT IS KEPT, AND WHY

- web_shell.html mirrors console errors onto the page, hidden until one
  occurs. Godot's errors go to the browser console, which is unreachable on
  a phone; this found in one shot what inference had failed to find all
  evening. It costs nothing when there is nothing to report.
- MainMenu._start does load -> instantiate -> add_child by hand instead of
  change_scene_to_file. That call does all three opaquely, so a failure is
  indistinguishable from an unpressed button; split, each step reports.
- PlanetAtmosphereController defers the forced world populate out of the
  frame that builds Main.tscn. It did not fix the hang - the trap did - but
  25 chunks of asteroids and 49 of planets do not belong in the one frame
  that already constructs 197 nodes, and nothing can paint until it ends.

Removed the direct-input-routing experiment and its test: it was written on
the theory that touches were not reaching Controls, which `held=y` later
disproved, and its set_input_as_handled() swallowed the very events the
probe was reading.

Full suite green at 100/100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 01:08:09 +02:00

115 lines
4.1 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_main_menu.gd
# Verifies:
# 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and
# reaches PlanetSetupEditor.tscn (#48)
# 2. there is no Quit entry in any build - a web build has nothing to quit
# to, and Android's back gesture already does it
# 3. Play resets GameState to a fresh game AND reaches Main.tscn
var menu: Node
var game_state: Node
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
game_state = root.get_node("GameState")
menu = load("res://MainMenu.tscn").instantiate()
root.add_child(menu)
current_scene = menu
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# A scene added to the tree inside _initialize() has not had its
# _ready() flushed yet - see test_planet_setups.gd for the same
# hazard - so the menu's @onready buttons aren't valid until a
# frame has actually processed.
if elapsed < 0.1:
return false
if not menu.editor_button.enabled:
return _fail("map editor button is not enabled (#48 shipped)")
# Both development entries follow the same rule, and the rule is
# what is under test rather than the concrete boolean - this run
# is from Godot, so it exercises the "shown" branch.
var developing: bool = menu.is_developer_build()
if menu.editor_button.visible != developing or menu.editor_label.visible != developing:
return _fail("map editor entry visibility did not follow is_developer_build()")
if menu.dev_button.visible != developing or menu.dev_label.visible != developing:
return _fail("dev mode entry visibility did not follow is_developer_build()")
if menu.has_node("UI/QuitButton") or menu.has_node("UI/QuitLabel"):
return _fail("the menu still carries a Quit entry")
_press(menu.editor_button)
elapsed = 0.0
stage = 1
return false
1:
# _start awaits a frame so its status text paints before the
# blocking load, so the change takes a couple of frames.
if elapsed < 1.0:
return false
var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor"
if not on_editor:
return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn")
# Return to the menu the same way the player would, then test Play.
menu = load("res://MainMenu.tscn").instantiate()
root.add_child(menu)
current_scene = menu
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.1:
return false
# Dirty the game state exactly like an in-progress run would,
# so a genuine reset is distinguishable from a coincidental match.
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999)
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
game_state.surviving_ship_count = 1
game_state.planet_capture["chunk:1:1"] = {"captured": [true], "conquered": true}
_press(menu.play_button)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 1.0:
return false
var on_main: bool = current_scene != null and current_scene.name == "Main"
# A normal game now starts with NOTHING banked - the opening
# mining run is the tutorial, and 500 free ore skipped it.
var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \
and game_state.surviving_ship_count == GameState.STARTING_SHIP_COUNT \
and game_state.planet_capture.is_empty()
print("on_main=%s reset_ok=%s rocks=%d buildings=%d ship_count=%d" % [
on_main, reset_ok, game_state.rocks, game_state.buildings.size(),
game_state.surviving_ship_count
])
if not on_main or not reset_ok:
return _fail("Play did not reset to a fresh game before reaching Main.tscn")
print("TEST_MAIN_MENU: PASS")
quit(0)
return true
return false
func _press(button: IconButton) -> void:
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.pressed = true
button._gui_input(down)
var up: InputEventScreenTouch = InputEventScreenTouch.new()
up.pressed = false
button._gui_input(up)
func _fail(message: String) -> bool:
print("TEST_MAIN_MENU: FAIL (%s)" % message)
quit(1)
return true