extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_world_map_ui.gd # Verifies the map UI: hidden by default, opening it recenters on the # fleet, a single-finger drag while the map is open pans the map content # instead of leaking through to the gameplay fleet-move/zoom controls, and # closing hides it again. # # Open and close are driven by TAPPING THE BUTTONS, not by calling open_map() # and close_map(). Calling them directly is what let this ship broken: the # map took over touch input while it was visible and swallowed it in _input(), # which runs before the GUI system - so the close button never saw the touch # and the map could not be closed at all on a touch-only device. Every part # of that was reachable from a test; none of it was reached, because the test # closed the map by calling the method the button was failing to reach. # # The open tap doubles as the harness check: if a synthetic tap could not be # delivered at all, the close tap would "fail" for a reason that has nothing # to do with the map. const POS_TOLERANCE: float = 3.0 var main_scene: Node var fleet: Node var camera: Camera2D var world_map: Control var content: Node2D var map_button: Control var close_button: Control var fleet_target_before: Vector2 var camera_zoom_before: float var content_pos_before_drag: Vector2 var elapsed: float = 0.0 var stage: int = 0 func _initialize() -> void: var packed: PackedScene = load("res://world/Main.tscn") main_scene = packed.instantiate() root.add_child(main_scene) fleet = main_scene.get_node("Fleet") camera = main_scene.get_node("Camera2D") world_map = main_scene.get_node("UI/WorldMap") content = main_scene.get_node("UI/WorldMap/MapContent") map_button = main_scene.get_node("UI/MapButton") close_button = main_scene.get_node("UI/WorldMap/CloseButton") func _process(delta: float) -> bool: elapsed += delta match stage: 0: if elapsed < 0.1: return false if world_map.visible: print("TEST_WORLD_MAP_UI: FAIL (map visible by default)") quit(1) return true _tap(map_button) stage = 1 elapsed = 0.0 return false 1: if elapsed < 0.1: return false var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL var viewport_center: Vector2 = main_scene.get_viewport_rect().size / 2.0 var expected_content_pos: Vector2 = viewport_center - fleet_local * content.scale.x var recenter_ok: bool = content.position.distance_to(expected_content_pos) <= POS_TOLERANCE print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [ world_map.visible, content.position, expected_content_pos, recenter_ok ]) if not world_map.visible: print("TEST_WORLD_MAP_UI: FAIL (tapping the map button did not open the map)") quit(1) return true if not (world_map.visible and recenter_ok): print("TEST_WORLD_MAP_UI: FAIL (open/recenter)") quit(1) return true fleet_target_before = fleet.fleet_target camera_zoom_before = camera.zoom.x content_pos_before_drag = content.position _touch(0, true, Vector2(200, 600)) stage = 2 elapsed = 0.0 return false 2: if elapsed < 0.1: return false _drag(0, Vector2(260, 640)) stage = 3 elapsed = 0.0 return false 3: if elapsed < 0.1: return false _touch(0, false, Vector2(260, 640)) # Headless mode's placeholder window doesn't match the configured # viewport size, so Godot's own stretch transform rescales raw # synthetic touch coordinates by some environment-specific factor # before delivery - checking an exact pixel count here would be # testing that artifact, not the pan logic. Instead verify the # drag moved the map in the right DIRECTION and roughly the right # PROPORTION (dragged twice as far on X as Y, so the pan should # reflect that same ratio), which the scaling factor cancels out of. var pan_delta: Vector2 = content.position - content_pos_before_drag var moved_ok: bool = pan_delta.length() > 1.0 var direction_ok: bool = pan_delta.x > 0.0 and pan_delta.y > 0.0 var ratio_ok: bool = pan_delta.y != 0.0 and abs((pan_delta.x / pan_delta.y) - 1.5) <= 0.1 var pan_ok: bool = moved_ok and direction_ok and ratio_ok var fleet_untouched: bool = fleet.fleet_target == fleet_target_before var zoom_untouched: bool = camera.zoom.x == camera_zoom_before print("pan_delta=%s moved_ok=%s direction_ok=%s ratio_ok=%s fleet_untouched=%s zoom_untouched=%s" % [ pan_delta, moved_ok, direction_ok, ratio_ok, fleet_untouched, zoom_untouched ]) if not (pan_ok and fleet_untouched and zoom_untouched): print("TEST_WORLD_MAP_UI: FAIL (pan/isolation)") quit(1) return true # The subject: a tap on the close button, delivered exactly the # way the tap that opened the map was. _tap(close_button) stage = 4 elapsed = 0.0 return false 4: if elapsed < 0.1: return false var closed_ok: bool = not world_map.visible print("closed_by_button=%s map_button_back=%s" % [closed_ok, map_button.visible]) if closed_ok and not map_button.visible: print("TEST_WORLD_MAP_UI: FAIL (map closed but its open button never came back)") quit(1) return true if closed_ok: print("TEST_WORLD_MAP_UI: PASS") quit(0) else: print("TEST_WORLD_MAP_UI: FAIL (did not close)") quit(1) return true return false # Taps a control at the centre of its own rect, pushed straight into the # viewport in local coordinates. Deliberately NOT Input.parse_input_event with # a hand-written position: headless rescales those through the stretch # transform (see the note in stage 3), which is fine for the pan check below - # it only cares about direction and ratio - but useless for hitting a target. # Its own touch index, away from the 0 the pan check below uses. push_input() # is delivered immediately while Input.parse_input_event() is queued for the # next frame, so the two can arrive out of order - and a release whose index # the map is still holding belongs to the map, correctly, which would make # this read as a broken button when nothing is broken. A finger cannot be in # two places at once, so distinct indices is what really happens anyway. const TAP_INDEX: int = 7 func _tap(control: Control) -> void: var at: Vector2 = control.get_global_rect().get_center() for pressed in [true, false]: var ev := InputEventScreenTouch.new() ev.index = TAP_INDEX ev.pressed = pressed ev.position = at control.get_viewport().push_input(ev, true) func _touch(index: int, pressed: bool, pos: Vector2) -> void: var ev: InputEventScreenTouch = InputEventScreenTouch.new() ev.index = index ev.pressed = pressed ev.position = pos Input.parse_input_event(ev) func _drag(index: int, pos: Vector2) -> void: var ev: InputEventScreenDrag = InputEventScreenDrag.new() ev.index = index ev.position = pos Input.parse_input_event(ev)