diff --git a/docs/testing/test_inventory.md b/docs/testing/test_inventory.md index f5c7a3f..6e89420 100644 --- a/docs/testing/test_inventory.md +++ b/docs/testing/test_inventory.md @@ -8,11 +8,12 @@ to know what "run the tests" actually checked. Run everything below in one shot with `scripts/run_checkpoint_tests.py`. Last verified against `main`: 2026-08-20 — full PC1 checkpoint run, -migrations + pytest + all 6 Godot smoke tests + both platform exports, all -clean. Two real bugs found and fixed via real playtesting this same day -(not caught by the checkpoint suite as it stood before today): the main -menu had no way to actually open the login/register form, and the world -list's cards rendered at ~1px wide once real data existed. Both are now +migrations + pytest + all 7 Godot smoke tests + both platform exports, all +clean. Real bugs found and fixed via real playtesting this same day (not +caught by the checkpoint suite as it stood before today): the main menu +had no way to actually open the login/register form, the world list's +cards rendered at ~1px wide once real data existed, and navigating Play → +Back logged the user out despite a still-valid session. All three are now covered by the smoke tests below so they can't silently regress. ## Backend — `backend/tests/` (pytest) @@ -52,7 +53,7 @@ these yet): ## Godot client — `scripts/smoke_test_*.gd` -**6 smoke tests merged into `main`.** Each needs a live backend — +**7 smoke tests merged into `main`.** Each needs a live backend — `AGP_API_BASE_URL` env var, falling back to the `application/agp/api_base_url` project setting (default `http://127.0.0.1:8000`) if unset. @@ -65,6 +66,7 @@ these yet): | `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. | | `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. | | `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. | +| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). | Run (once, to build the class cache): `godot --headless --path . --import`, then for each smoke test: `godot --headless --path . --script diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index 1208508..2cc632d 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -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 diff --git a/scripts/smoke_test_main_menu_back.gd b/scripts/smoke_test_main_menu_back.gd new file mode 100644 index 0000000..75e25b7 --- /dev/null +++ b/scripts/smoke_test_main_menu_back.gd @@ -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) diff --git a/scripts/smoke_test_main_menu_full_flow.gd b/scripts/smoke_test_main_menu_full_flow.gd index a94a207..71cc34d 100644 --- a/scripts/smoke_test_main_menu_full_flow.gd +++ b/scripts/smoke_test_main_menu_full_flow.gd @@ -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_"))