Fix broken main_menu.gd on main + land Play->Back session persistence
Two things, found together while investigating a stuck blocker task:
1. URGENT: main_menu.gd currently fails to parse at all on main
("Identifier '_dob_edit' not declared in the current scope"). PR #44
(hide-all-fields-on-auth-success) and PR #45 (DOBEdit -> Over18Check
checkbox) each merged cleanly on their own -- Gitea's merge reported
mergeable:true for both -- but together they left a reference to the
old _dob_edit var on a line neither PR's diff touched, so no conflict
was ever flagged. Same failure mode in smoke_test_main_menu_full_flow.gd
(dob_edit, would have failed to even parse in the checkpoint suite).
Fixed both to reference _over_18_check / over_18_check.
2. Lands the Play -> Back session-persistence fix (PR #43, task
28357bca192d) that was stuck behind review the whole time main didn't
have it yet: signal auth_state_ready, _show_authenticated() /
_reset_to_logged_out() helpers, and _ready() restoring the
authenticated UI (not resetting it) when ApiClient already holds a
valid token. Plus the real fix a review round caught on top of that
(task 9186405a6373): _show_authenticated() was hiding AuthPanel while
claiming the welcome label inside it was visible -- a no-op UI-wise,
since WelcomeLabel.visible=true does nothing once its parent is
hidden. AuthPanel now stays visible; the regression test
(smoke_test_main_menu_back.gd, new) asserts is_visible_in_tree(), not
just .visible, so this class of bug can't pass silently again.
Verified against a live backend: 61/61 pytest, 7/7 Godot smoke tests
(including the new smoke_test_main_menu_back.gd, 12/12).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
This commit is contained in:
+60
-2
@@ -10,6 +10,20 @@ extends Control
|
||||
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||
## the welcome label shows the username, and the Play button becomes enabled.
|
||||
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||
##
|
||||
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||
## UI state (Play enabled, welcome message visible) instead of forcing the user
|
||||
## back to the login form.
|
||||
|
||||
|
||||
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||
## anything else) know the UI state is trustworthy now, rather than
|
||||
## guessing from a frame count or from UI properties that are ambiguous
|
||||
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||
## resolves AND in a genuine logged-out end state).
|
||||
signal auth_state_ready
|
||||
|
||||
# ─── UI references ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,7 +65,51 @@ func _ready() -> void:
|
||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||
|
||||
# Initial state.
|
||||
# Default to Play being gated. When a token exists, the async session
|
||||
# check below may take several frames (a real get_me() round-trip);
|
||||
# keeping the button disabled until validation succeeds prevents a fast
|
||||
# click from navigating to world_list with an expired/invalid token.
|
||||
_play_button.disabled = true
|
||||
|
||||
# Check whether a valid session already exists (e.g. user logged in,
|
||||
# navigated to world_list, then clicked Back). If so, restore the
|
||||
# authenticated UI state instead of forcing the login form again.
|
||||
if ApiClient.has_token():
|
||||
var me_result: Dictionary = await ApiClient.get_me()
|
||||
if me_result.ok and me_result.data is Dictionary:
|
||||
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||
if username != "":
|
||||
_show_authenticated(username)
|
||||
auth_state_ready.emit()
|
||||
return
|
||||
|
||||
# Token missing or invalid — fall through to logged-out state.
|
||||
_reset_to_logged_out()
|
||||
auth_state_ready.emit()
|
||||
|
||||
|
||||
## Restore the UI to the authenticated state (Play enabled, welcome message
|
||||
## visible, auth buttons hidden). AuthPanel itself stays visible -- the
|
||||
## welcome label lives inside it, so hiding the panel hides the label too
|
||||
## even when the label's own `visible` is true (real bug, 2026-08-20: a
|
||||
## review caught the panel being hidden here while the label claimed to be
|
||||
## visible, which is a no-op UI-wise since Control.visible doesn't override
|
||||
## an ancestor's hidden state).
|
||||
func _show_authenticated(username: String) -> void:
|
||||
_auth_panel.visible = true
|
||||
_status_label.visible = false
|
||||
_welcome_label.text = "Welcome, %s!" % username
|
||||
_welcome_label.visible = true
|
||||
_email_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Restored authenticated session for: ", username)
|
||||
|
||||
|
||||
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||
## login/register buttons visible).
|
||||
func _reset_to_logged_out() -> void:
|
||||
_auth_panel.visible = false
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
@@ -166,7 +224,7 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_auth_button.visible = false
|
||||
_auth_mode_label.visible = false
|
||||
_status_label.visible = false
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
extends SceneTree
|
||||
## Regression test for the Play -> Back navigation bug.
|
||||
##
|
||||
## The reported bug: after logging in and clicking Play, the user lands on
|
||||
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||
## used to unconditionally reset the UI on every scene load.
|
||||
##
|
||||
## This test drives the real navigation flow (change_scene_to_file) against
|
||||
## a live backend:
|
||||
## 1. Register a unique user so ApiClient holds a valid token.
|
||||
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||
## 3. Click Play -> world_list.tscn.
|
||||
## 4. Click Back -> main_menu.tscn.
|
||||
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||
## actually visible in the tree, login/register buttons hidden).
|
||||
##
|
||||
## Run with:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||
##
|
||||
## Requires the backend to be running (default base URL
|
||||
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||
|
||||
const USERNAME := "backnav_%d"
|
||||
const EMAIL := "backnav_%d@test.example.com"
|
||||
const PASSWORD := "BackNav123!"
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var api := root.get_node("ApiClient")
|
||||
if api == null:
|
||||
printerr("FAIL: ApiClient autoload not found")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# 1. Establish a real, valid session exactly the way a user would.
|
||||
randomize()
|
||||
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||
var username := USERNAME % unique_id
|
||||
var email := EMAIL % unique_id
|
||||
print("\n=== Setup: register ", username, " ===")
|
||||
var register_result: Dictionary = await api.register(username, email, PASSWORD, true)
|
||||
if not _check("register succeeds", register_result.ok):
|
||||
print(" detail: ", register_result.detail)
|
||||
_finish()
|
||||
return
|
||||
_check("ApiClient stores token after register", api.has_token())
|
||||
|
||||
# 2. First visit to the main menu: should already show the authenticated
|
||||
# state (no login form, Play enabled).
|
||||
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||
# plenty for the scene object itself to exist (unlike the real
|
||||
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||
# actually waits for below).
|
||||
for i in 5:
|
||||
await process_frame
|
||||
if current_scene is Control:
|
||||
break
|
||||
|
||||
var menu: Control = current_scene
|
||||
if menu == null or not menu is Control:
|
||||
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||
_finish()
|
||||
return
|
||||
await menu.auth_state_ready
|
||||
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||
|
||||
# 3. Play -> world_list.tscn (real button press).
|
||||
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
play_button.pressed.emit()
|
||||
await _wait_for_scene_change(menu)
|
||||
|
||||
var world_list_scene := current_scene
|
||||
if world_list_scene == null or world_list_scene == menu:
|
||||
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# 4. Back -> main_menu.tscn (real button press).
|
||||
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||
back_button.pressed.emit()
|
||||
await _wait_for_scene_change(world_list_scene)
|
||||
|
||||
menu = current_scene
|
||||
if menu == null or not menu is Control:
|
||||
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||
# object exists, not that its authenticated-state fields have been
|
||||
# set yet. Wait for the real signal, not a guess.
|
||||
await menu.auth_state_ready
|
||||
|
||||
# 5. THE regression assertion: still logged in after returning.
|
||||
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||
|
||||
_finish()
|
||||
|
||||
|
||||
func _is_authenticated(menu: Control) -> bool:
|
||||
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||
|
||||
_check("Play enabled", not play_button.disabled)
|
||||
# WelcomeLabel lives inside AuthPanel, so `.visible` alone is a
|
||||
# false-positive: the label can have visible=true while its parent is
|
||||
# hidden and it renders as nothing. `is_visible_in_tree()` is the
|
||||
# check that proves the user can actually see the welcome message.
|
||||
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||
_check("Auth panel visible (it contains the welcome label)", auth_panel.visible)
|
||||
|
||||
return (not play_button.disabled) \
|
||||
and welcome_label.is_visible_in_tree() \
|
||||
and not auth_buttons_row.visible \
|
||||
and auth_panel.visible
|
||||
|
||||
|
||||
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||
for i in 20:
|
||||
await process_frame
|
||||
if current_scene != previous_scene and current_scene != null:
|
||||
return
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
return condition
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
@@ -60,7 +60,7 @@ func _run() -> void:
|
||||
await instance._on_auth_button_pressed()
|
||||
|
||||
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||
_check("Welcome label visible", welcome_label.visible)
|
||||
_check("Welcome label visible in tree", welcome_label.is_visible_in_tree())
|
||||
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||
|
||||
# Verify the auth form fields are hidden after successful auth,
|
||||
@@ -68,7 +68,7 @@ func _run() -> void:
|
||||
_check("Username edit hidden after auth", not username_edit.visible)
|
||||
_check("Password edit hidden after auth", not password_edit.visible)
|
||||
_check("Email edit hidden after auth", not email_edit.visible)
|
||||
_check("DOB edit hidden after auth", not dob_edit.visible)
|
||||
_check("Over18 checkbox hidden after auth", not over_18_check.visible)
|
||||
_check("Auth button hidden after auth", not auth_button.visible)
|
||||
_check("Auth mode label hidden after auth", not auth_mode_label.visible)
|
||||
_check("Welcome label contains username", welcome_label.text.begins_with("Welcome, mainmenu_e2e_"))
|
||||
|
||||
Reference in New Issue
Block a user