Files
settled-reach/client/tests/test_merge_path_flows_sprint37.gd
T
jpmschweitzerandClaude Opus 4.7 bc310a3ca5 fix(client): sprint 37 test-pass fixes — parse error + test hygiene
- character_creation.gd:1614: type-annotate dir_name to fix GDScript
  inference parse error. CARDINAL_DIRS is an untyped const Array, so
  `var dir_name := CARDINAL_DIRS[idx]` failed type inference and blocked
  test_character_creation_sprint28.gd test discovery. Runtime was lenient
  but gdUnit4's scanner is strict.
- test_merge_path_flows_sprint37.gd (_load_char_create): call add_child
  before _update_start_btn_state so @onready vars (_footer_start) are
  bound when the helper dereferences them. Unblocks 2 merge-path tests.
- test_anti_tedium.gd (test_bug_report_sends_unpause_on_close): call
  dialog.close() instead of the old dialog._close() rename casualty.
  `on_close()` is a lifecycle hook — it doesn't actually transition
  state; `close()` is what MetaScreen exposes.
- test_anti_tedium.gd (before_test): clear MetaStack._stack. Prior tests
  leave stale freed dialog refs on the stack; _any_pausing() iterates
  the stack during close() and crashes with "previously freed".

Net: sprint-37 test files (test_merge_path_flows_sprint37.gd,
test_anti_tedium.gd) now pass. Overall suite: 2428/2488 passing
(60 remaining failures are pre-existing, unrelated to sprint 37).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:06:51 +02:00

320 lines
12 KiB
GDScript

## Sprint 37 — Scene-level merge-path UI flow tests (#873)
##
## Four flows that cover the critical paths through the pre-game UI.
## These tests are the merge-gate mechanism added in the Sprint 36 retro:
## regressions like #872 (New Game hang) must be caught here, not in post-merge
## smoke tests.
##
## Pattern: load scene → simulate input via button.pressed.emit() or direct
## handler call → assert terminal state. No pixel diffing, no xdotool.
##
## NOTE: Tests that end in a scene transition (change_scene_to_file) assert state
## synchronously before the deferred transition fires. The test scene is
## queue_freed in after_test() regardless.
##
## Reference: test_character_creation_sprint28.gd
## Ticket: #873 | motivating regression: #872
class_name TestMergePathFlowsSprint37
extends GdUnitTestSuite
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
const CHAR_CREATE_SCENE_PATH := "res://scenes/character_creation.tscn"
var _scene = null # MainMenu or CharacterCreation — untyped, varies per test
func before_test() -> void:
# Reset all shared state that these flows touch
SimBridge.disconnect_from_sim()
SimBridge._last_snapshot = null
SimBridge._outbound_buffer.clear()
GameState.bookmark_catalog = []
GameState.pending_load_path = ""
# Clear MetaStack from any leftover overlays to prevent push/pop ordering issues
MetaStack._stack.clear()
func after_test() -> void:
if is_instance_valid(_scene):
_scene.queue_free()
_scene = null
SimBridge.disconnect_from_sim()
SimBridge._outbound_buffer.clear()
MetaStack._stack.clear()
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
func _load_main_menu() -> void:
var packed := load(MAIN_MENU_SCENE_PATH) as PackedScene
if packed == null:
push_warning("TestMergePathFlowsSprint37: main_menu.tscn not found — skipping")
return
_scene = packed.instantiate()
add_child(_scene)
func _load_char_create() -> void:
var packed := load(CHAR_CREATE_SCENE_PATH) as PackedScene
if packed == null:
push_warning("TestMergePathFlowsSprint37: character_creation.tscn not found — skipping")
return
_scene = packed.instantiate()
# Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1).
# Individual tests override these as needed. add_child() must run first so
# _ready() populates @onready vars (_footer_start, etc.) that
# _update_start_btn_state() dereferences.
add_child(_scene)
_scene._selected_bookmark_id = "test-bookmark"
_scene._selected_location_id = "test-location"
if _scene.has_method("_update_start_btn_state"):
_scene._update_start_btn_state()
func _make_catalog_snapshot() -> Dictionary:
## Minimal valid snapshot with a bookmark_catalog for flow-1 testing.
return {
"tick": 0,
"version": 23,
"entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Full"},
"player_facing": "North",
"player_stance": "Walk",
"player_inventory": [],
"visible_tiles": [],
"nearby_interactions": [],
"pending_recognitions": [],
"bookmark_catalog": {
"bookmarks": [
{
"id": "bm_tycoon_arion",
"title": "Arion Freight Broker",
"subtitle": "Start at Arion orbital",
"flavor": "Commodities and logistics.",
"default_location": "arion",
"allowed_locations": ["arion", "arion_low"],
"allowed_locations_cultures": ["arion"],
"career": "tycoon",
"starting_capital_tractus": 50000,
}
]
},
}
# =============================================================================
# Flow 1: main menu → new game → loading state → catalog received → resolved
# =============================================================================
# Regression guard for #872: New Game used to hang on "Connecting to simulation..."
# because bookmark_catalog was overwritten in receive_bytes before poll_snapshot consumed it.
# This test catches that regression by verifying the full state machine:
# pressed → loading visible → catalog signal → loading dismissed.
func test_new_game_shows_loading_screen() -> void:
## Pressing New Game must show the loading screen and set _waiting_for_catalog.
_load_main_menu()
if _scene == null:
return
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be false before New Game is pressed"
).is_false()
# Press New Game via the button signal (same as real player input)
_scene._new_game_btn.pressed.emit()
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be true after New Game pressed"
).is_true()
assert_that(_scene._loading_screen).override_failure_message(
"Loading screen instance must exist after New Game pressed"
).is_not_null()
assert_bool(_scene._loading_screen.visible).override_failure_message(
"Loading screen must be visible after New Game pressed"
).is_true()
func test_new_game_catalog_snapshot_resolves_loading_state() -> void:
## When snapshot_received fires with a bookmark_catalog, the loading state must clear.
## This is the exact regression introduced in #872 — if the catalog is never delivered,
## _waiting_for_catalog stays true and the screen hangs forever.
_load_main_menu()
if _scene == null:
return
# Simulate the New Game press to set up the signal subscription and loading state.
# In test mode, connect_to_sim() immediately fires CONNECTED, which triggers
# _on_sim_state_changed_for_new_game and connects snapshot_received.
_scene._new_game_btn.pressed.emit()
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"Precondition: _waiting_for_catalog must be true before catalog arrives"
).is_true()
# Deliver the catalog via the signal path (same path the server uses in live mode).
# snapshot_received is emitted here directly because in test mode poll_snapshot()
# uses the harness snapshot (no catalog). The carry-forward fix (#872) ensures
# this signal path also works correctly in live mode when ticks batch.
var catalog_snapshot := _make_catalog_snapshot()
SimBridge.snapshot_received.emit(catalog_snapshot)
# Terminal state: loading resolved
assert_bool(_scene._waiting_for_catalog).override_failure_message(
"_waiting_for_catalog must be false after catalog snapshot delivered — #872 regression"
).is_false()
assert_bool(GameState.bookmark_catalog.size() > 0).override_failure_message(
"GameState.bookmark_catalog must be populated after catalog snapshot applied"
).is_true()
assert_str(GameState.bookmark_catalog[0].get("id", "")).override_failure_message(
"Catalog entry must have the expected bookmark id"
).is_equal("bm_tycoon_arion")
# =============================================================================
# Flow 2: main menu → load game → save picker → save selected
# =============================================================================
func test_load_game_save_picker_shows_on_browse() -> void:
## Calling _on_load_game_browse() must show the save picker panel.
_load_main_menu()
if _scene == null:
return
assert_bool(_scene._load_panel.visible).override_failure_message(
"Load panel must be hidden before Load Game is pressed"
).is_false()
_scene._on_load_game_browse()
assert_bool(_scene._load_panel.visible).override_failure_message(
"Load panel must be visible after _on_load_game_browse()"
).is_true()
func test_load_game_save_selection_sets_pending_load_path() -> void:
## Selecting a save entry must set GameState.pending_load_path for main.gd to consume.
_load_main_menu()
if _scene == null:
return
var mock_save := {
"game_id": "20260421-120000-abc123",
"newest_save": "quicksave.sav",
}
# Call _on_save_selected directly — mirrors what the generated save-list button does.
_scene._on_save_selected(mock_save)
assert_str(GameState.pending_load_path).override_failure_message(
"pending_load_path must be set to the selected save's full path"
).is_equal("user://saves/20260421-120000-abc123/quicksave.sav")
# =============================================================================
# Flow 3: character creation → submit → sim_bridge receives correct payload
# =============================================================================
func test_character_creation_submit_sends_confirm_bookmark_action() -> void:
## _on_start() must queue a ConfirmBookmark action with the selected bookmark
## and location IDs. This is the payload the server uses to initialize the run.
_load_char_create()
if _scene == null:
return
# Ensure SimBridge is connected so send_named_action doesn't silently drop the action
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
_scene._selected_bookmark_id = "bm_tycoon_arion"
_scene._selected_location_id = "arion"
if _scene.has_method("_update_start_btn_state"):
_scene._update_start_btn_state()
SimBridge._outbound_buffer.clear()
_scene._on_start()
var found := false
for entry in SimBridge._outbound_buffer:
if entry.get("action_name") == "ConfirmBookmark":
var data: Variant = entry.get("action_data")
if (
data is Dictionary
and data.get("bookmark_id") == "bm_tycoon_arion"
and data.get("starting_location_id") == "arion"
):
found = true
break
assert_bool(found).override_failure_message(
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion'}"
).is_true()
# =============================================================================
# Flow 4: bookmark tab → select location → confirm → server gets bookmark action
# =============================================================================
func test_bookmark_tab_select_location_then_confirm_queues_action() -> void:
## Exercises the full selection path: picking a bookmark, picking a location, then
## confirming. Verifies the correct bookmark_id + starting_location_id reach the server.
## This is the flow the player actually takes — selection handlers must propagate
## to the outbound buffer correctly.
# Populate catalog before scene instantiation so _build_bookmark_cards() sees it
GameState.bookmark_catalog = [
{
"id": "bm_tycoon_arion",
"title": "Arion Freight Broker",
"subtitle": "Start at Arion orbital",
"flavor": "Commodities and logistics.",
"default_location": "arion",
"allowed_locations": ["arion", "arion_low"],
"allowed_locations_cultures": ["arion"],
"career": "tycoon",
"starting_capital_tractus": 50000,
}
]
_load_char_create()
if _scene == null:
return
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
SimBridge._outbound_buffer.clear()
# Simulate the player selecting the bookmark card
var bm: Dictionary = GameState.bookmark_catalog[0]
_scene._on_bookmark_selected(bm)
assert_str(_scene._selected_bookmark_id).override_failure_message(
"_on_bookmark_selected must update _selected_bookmark_id"
).is_equal("bm_tycoon_arion")
assert_str(_scene._selected_location_id).override_failure_message(
"_on_bookmark_selected must populate _selected_location_id from default_location"
).is_not_empty()
# Simulate the player picking a specific allowed location
_scene._on_location_selected("arion_low")
assert_str(_scene._selected_location_id).override_failure_message(
"_on_location_selected must update _selected_location_id"
).is_equal("arion_low")
# Confirm — sends the action to the server
_scene._on_start()
var found := false
for entry in SimBridge._outbound_buffer:
if entry.get("action_name") == "ConfirmBookmark":
var data: Variant = entry.get("action_data")
if (
data is Dictionary
and data.get("bookmark_id") == "bm_tycoon_arion"
and data.get("starting_location_id") == "arion_low"
):
found = true
break
assert_bool(found).override_failure_message(
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion_low'}"
).is_true()