- 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>
320 lines
12 KiB
GDScript
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()
|