Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e51c4d80b8 | |||
| 00cdab0452 | |||
| c287202b9d | |||
| c01f9de586 | |||
| 0505cd7ff1 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| f8e5de9293 | |||
| 3a7b2ab306 |
@@ -0,0 +1,72 @@
|
||||
# Package Checkpoint 1 (PC1)
|
||||
|
||||
Validated 2026-08-20 against `main` commit `927f9bd`, from a genuinely fresh
|
||||
clone (not a reused agent workspace).
|
||||
|
||||
## Automated validation
|
||||
|
||||
Run with `scripts/run_checkpoint_tests.py` — see
|
||||
`docs/testing/test_inventory.md` for exactly what each suite covers.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Alembic migrations, fresh DB | PASS |
|
||||
| Backend pytest | 68/68 PASS |
|
||||
| Godot smoke tests (4 files) | ALL PASS |
|
||||
| Linux export (`agp-game.x86_64`) | Builds, verified as a real ELF64 binary |
|
||||
| Windows export (`agp-game.exe`) | Builds, verified as a real PE32+ binary |
|
||||
|
||||
## Problems found during this checkpoint (fixed, tracked as tasks)
|
||||
|
||||
- Roadmap showed the Godot Engine Scaffold feature amber despite 6/6 tasks
|
||||
done — a historical data gap (a review-feedback follow-up task's
|
||||
supersession link was never recorded, predating when that field started
|
||||
being tracked). Backfilled in the PM database; confirmed green.
|
||||
- `docs/testing/test_inventory.md` was stale (said 38 backend tests,
|
||||
actually 68; said no integration tests existed, 4 real ones had landed).
|
||||
Fixed via task `398bf1a22a2d` / PR #40.
|
||||
|
||||
## What's actually testable right now
|
||||
|
||||
Login/register/browse-worlds only — there's no joinable gameplay yet. That's
|
||||
the real scope of PC1; don't expect more when playtesting.
|
||||
|
||||
- Register a new account (18+ only, rejected otherwise)
|
||||
- Log out, log back in with the same credentials
|
||||
- Play → Browse Worlds — loads a (currently empty) world list with no errors
|
||||
|
||||
## Manual playtest: backend on cloudbison, client on pinkbison
|
||||
|
||||
As of 2026-08-20 this is dashboard-controlled — no terminal commands, no
|
||||
env vars, no editing files. Foolproof enough to hand to someone else.
|
||||
|
||||
### 1. On cloudbison — the AGP dashboard
|
||||
|
||||
Open the dashboard (`http://cloudbison:7070`, or its Tailscale address) and
|
||||
go to **PC1** in the nav bar (`/checkpoint/PC1`). Click **Start Backend** —
|
||||
it pulls the latest `main`, stands up Postgres, runs migrations, and starts
|
||||
the API server, all in one step. The page shows the LAN address it's
|
||||
reachable at once it's up (under a minute).
|
||||
|
||||
When you're done playtesting, click **Stop & Clean Up** on the same page —
|
||||
it tears down everything that step started (API server, Postgres
|
||||
container, its volume and network). Nothing is left running or lying
|
||||
around afterward, even if the dashboard itself gets restarted in between.
|
||||
|
||||
### 2. On pinkbison — download and run the .exe
|
||||
|
||||
Same dashboard page: click **Build .exe** the first time (or **Rebuild** to
|
||||
pick up newer changes) — this pulls the latest `main` and exports a Windows
|
||||
binary with cloudbison's backend address already baked in. Click
|
||||
**Download agp-game.exe**, copy it to pinkbison, and just run it. No
|
||||
environment variable, no config file, no build step on pinkbison at all.
|
||||
|
||||
### 3. Tear down when done
|
||||
|
||||
**Stop & Clean Up** on the dashboard page. That's it.
|
||||
|
||||
## Status
|
||||
|
||||
`config/checkpoint_focus_feature_ids` in the AGP framework (`~/agp`) still
|
||||
restricts agent claiming to the 5 PC1-scope features. Agents will not move
|
||||
on to post-PC1 work until that's widened.
|
||||
@@ -7,9 +7,13 @@ 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 (commit `927f9bd`) — full PC1
|
||||
checkpoint run, fresh clone, migrations + pytest + all 4 Godot smoke tests +
|
||||
both platform exports, all clean.
|
||||
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
|
||||
covered by the smoke tests below so they can't silently regress.
|
||||
|
||||
## Backend — `backend/tests/` (pytest)
|
||||
|
||||
@@ -48,7 +52,7 @@ these yet):
|
||||
|
||||
## Godot client — `scripts/smoke_test_*.gd`
|
||||
|
||||
**4 smoke tests merged into `main`.** Each needs a live backend —
|
||||
**6 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.
|
||||
@@ -58,7 +62,9 @@ these yet):
|
||||
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
|
||||
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). |
|
||||
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
|
||||
| `smoke_test_world_list.gd` | Instantiates `world_list.tscn` as a real scene, adds to tree (catches `@onready` node-path bugs), waits for `_refresh_worlds()` to complete, confirms the screen reaches a real end state (world cards, empty-list message, or error label — not stuck loading). |
|
||||
| `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. |
|
||||
|
||||
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||
then for each smoke test: `godot --headless --path . --script
|
||||
@@ -66,14 +72,18 @@ res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||
their awaited HTTP calls finish.
|
||||
|
||||
**Known gaps:**
|
||||
- No smoke tests for the main menu scene (`main_menu.tscn`).
|
||||
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||
- No smoke tests for dynamic/static object nodes.
|
||||
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||
- `smoke_test_world_list.gd` is the only one that exercises a real `.tscn`
|
||||
scene instantiation — others only test the `ApiClient` autoload.
|
||||
- `smoke_test_world_list.gd` and `smoke_test_main_menu_*.gd` are the only
|
||||
ones that exercise a real `.tscn` scene instantiation — others only test
|
||||
the `ApiClient` autoload directly.
|
||||
- `get_me()` (`GET /auth/me`) is implemented in `scripts/api_client.gd` but
|
||||
has no smoke coverage yet.
|
||||
- The Login path (as opposed to Register) through `main_menu.gd` isn't
|
||||
separately smoke-tested — it shares the same `_on_auth_button_pressed()`/
|
||||
`_on_auth_complete()` code path already exercised for Register, but that's
|
||||
inference, not a direct check.
|
||||
|
||||
## Client export
|
||||
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ script_export_mode=2
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=false
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
@@ -52,7 +52,7 @@ script_export_mode=2
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=false
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
|
||||
@@ -29,6 +29,21 @@ text = "AGP Game"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 72
|
||||
|
||||
[node name="AuthButtonsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="LoginButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||
layout_mode = 1
|
||||
text = "Login"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="RegisterButton" type="Button" parent="CenterContainer/VBoxContainer/AuthButtonsRow"]
|
||||
layout_mode = 1
|
||||
text = "Register"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
@@ -106,6 +121,8 @@ layout_mode = 1
|
||||
text = "Quit"
|
||||
size_flags_horizontal = 4
|
||||
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton" to="." method="_on_login_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton" to="." method="_on_register_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
||||
[connection signal="toggled" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle" to="." method="_on_mode_toggle_toggled"]
|
||||
|
||||
+11
-9
@@ -11,14 +11,17 @@ grow_vertical = 2
|
||||
script = ExtResource("1_wl")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 3
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 8
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
|
||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
|
||||
layout_mode = 3
|
||||
text = "Browse Worlds"
|
||||
horizontal_alignment = 1
|
||||
size_flags_horizontal = 4
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 28
|
||||
|
||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
horizontal_mode = 2
|
||||
vertical_mode = 2
|
||||
|
||||
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||
layout_mode = 3
|
||||
size_flags_horizontal = 4
|
||||
size_flags_vertical = 4
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||
|
||||
+81
-19
@@ -1,17 +1,33 @@
|
||||
extends Control
|
||||
## Main menu with login/register UI wired to the backend auth API.
|
||||
##
|
||||
## The AuthPanel is hidden by default. When the user clicks "Login" or "Register"
|
||||
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle
|
||||
## switches between Login and Register modes, showing/hiding the email and DOB
|
||||
## fields accordingly.
|
||||
## The AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||
## The ModeToggle switches between Login and Register modes, showing/hiding
|
||||
## the email and DOB fields accordingly.
|
||||
##
|
||||
## On successful auth the token is stored via ApiClient, the welcome label
|
||||
## shows the username, and the Play button becomes enabled.
|
||||
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||
## the welcome label shows the username, and the Play button becomes enabled.
|
||||
##
|
||||
## 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 label visible) instead of forcing the user
|
||||
## back to the login form.
|
||||
|
||||
|
||||
# ── UI references ────────────────────────────────────────────────────────────
|
||||
## 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
var _auth_buttons_row: HBoxContainer
|
||||
var _login_button: Button
|
||||
var _register_button: Button
|
||||
var _auth_panel: PanelContainer
|
||||
var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
@@ -29,10 +45,13 @@ var _is_register_mode: bool = false
|
||||
var _is_authenticating: bool = false
|
||||
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _ready() -> void:
|
||||
# Cache node references.
|
||||
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||
_register_button = $CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton
|
||||
_auth_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
@@ -46,7 +65,46 @@ 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 label,
|
||||
## auth buttons hidden).
|
||||
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
|
||||
_dob_edit.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
|
||||
@@ -56,7 +114,7 @@ func _ready() -> void:
|
||||
_update_auth_mode_ui()
|
||||
|
||||
|
||||
# ── Play / Quit ──────────────────────────────────────────────────────────────
|
||||
# ─── Play / Quit ─────────────────────────────────────────────────────────────
|
||||
|
||||
func _on_play_button_pressed() -> void:
|
||||
if _play_button.disabled:
|
||||
@@ -70,24 +128,25 @@ func _on_quit_button_pressed() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
# ── Auth panel toggle ────────────────────────────────────────────────────────
|
||||
# ─── Auth panel toggle ───────────────────────────────────────────────────────
|
||||
|
||||
func _on_login_button_pressed() -> void:
|
||||
"""Show the auth panel (called from a future Login button on the main menu)."""
|
||||
"""Show the auth panel, called from AuthButtonsRow/LoginButton."""
|
||||
_auth_panel.visible = true
|
||||
_auth_panel.grab_focus()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
func _on_register_button_pressed() -> void:
|
||||
"""Show the auth panel in register mode."""
|
||||
"""Show the auth panel in register mode, called from AuthButtonsRow/RegisterButton."""
|
||||
_auth_panel.visible = true
|
||||
_is_register_mode = true
|
||||
_mode_toggle.button_pressed = true
|
||||
_update_auth_mode_ui()
|
||||
_clear_status()
|
||||
_username_edit.grab_focus()
|
||||
|
||||
|
||||
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
|
||||
# ─── Mode toggle (Login / Register) ──────────────────────────────────────────
|
||||
|
||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
||||
_is_register_mode = button_pressed
|
||||
@@ -109,7 +168,7 @@ func _update_auth_mode_ui() -> void:
|
||||
_dob_edit.visible = false
|
||||
|
||||
|
||||
# ── Auth submission ──────────────────────────────────────────────────────────
|
||||
# ─── Auth submission ─────────────────────────────────────────────────────────
|
||||
|
||||
func _on_auth_button_pressed() -> void:
|
||||
"""Handle login or register button press."""
|
||||
@@ -166,18 +225,21 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_is_authenticating = false
|
||||
|
||||
if result.ok:
|
||||
# Success — show welcome, enable Play.
|
||||
# Success — show welcome, enable Play, hide the now-redundant
|
||||
# Login/Register entry points (the auth panel itself stays open,
|
||||
# showing the welcome message in place of the form).
|
||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||
_welcome_label.visible = true
|
||||
_status_label.visible = false
|
||||
_play_button.disabled = false
|
||||
_auth_buttons_row.visible = false
|
||||
print("Auth successful. Token stored.")
|
||||
else:
|
||||
# Failure — show backend error detail.
|
||||
_show_status(result.detail)
|
||||
|
||||
|
||||
# ── Status helpers ───────────────────────────────────────────────────────────
|
||||
# ─── Status helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
_status_label.text = message
|
||||
@@ -189,7 +251,7 @@ func _clear_status() -> void:
|
||||
_status_label.visible = false
|
||||
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────────────────────
|
||||
# ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
func _is_valid_date(date_str: String) -> bool:
|
||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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!"
|
||||
const DOB := "2000-01-01"
|
||||
|
||||
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, DOB)
|
||||
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)
|
||||
@@ -0,0 +1,76 @@
|
||||
extends SceneTree
|
||||
## Full click-through simulation of the real user flow: press Register,
|
||||
## fill the form exactly as a human would, submit, confirm Play actually
|
||||
## becomes clickable. This is the check that would have caught the
|
||||
## original bug (no way to even open the form) and would catch a repeat
|
||||
## of it.
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||
var instance: Control = packed.instantiate()
|
||||
root.add_child(instance)
|
||||
await process_frame
|
||||
|
||||
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||
|
||||
_check("Play starts disabled", play_button.disabled)
|
||||
|
||||
# Click Register -- a real signal emission is fine here since
|
||||
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||
# real click's GUI event dispatch would drive it.
|
||||
register_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||
|
||||
# Fill the form exactly as a user would type into it.
|
||||
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||
password_edit.text = "TestPass123!"
|
||||
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||
dob_edit.text = "2000-01-01"
|
||||
|
||||
# Call the submit handler directly (awaited) rather than via
|
||||
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||
# SceneTree headless mode doesn't drive an await-using handler's
|
||||
# coroutine forward the same way real GUI input event dispatch does
|
||||
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||
# its own await point even after 50 real process_frame waits). This
|
||||
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||
# click-driven flow.
|
||||
await instance._on_auth_button_pressed()
|
||||
|
||||
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||
_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)
|
||||
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> void:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
@@ -0,0 +1,60 @@
|
||||
extends SceneTree
|
||||
## Verifies the main menu's Login/Register buttons actually open the auth
|
||||
## panel via a real signal emission (not calling the handler method
|
||||
## directly) -- that's the exact gap that shipped without ever being
|
||||
## caught: the AuthPanel and its logic were all correct, but nothing in
|
||||
## the scene was ever wired to open it.
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||
var instance: Control = packed.instantiate()
|
||||
root.add_child(instance)
|
||||
await process_frame
|
||||
|
||||
var login_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton")
|
||||
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||
var auth_panel: PanelContainer = instance.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||
|
||||
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
login_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
if _check("auth panel visible after a real LoginButton press signal", auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Reset and check RegisterButton independently too.
|
||||
auth_panel.visible = false
|
||||
register_button.pressed.emit()
|
||||
await process_frame
|
||||
|
||||
if _check("auth panel visible after a real RegisterButton press signal", auth_panel.visible):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
return condition
|
||||
@@ -16,15 +16,27 @@ extends SceneTree
|
||||
##
|
||||
## Tests:
|
||||
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
||||
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||
## 2. POST a real world via the API so the populated-card path actually
|
||||
## gets exercised -- run_checkpoint_tests.py always starts from a
|
||||
## genuinely empty DB, so without this the card-rendering code below
|
||||
## would never run in CI at all, only the empty-list path would.
|
||||
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
|
||||
## must not error.
|
||||
## 3. Wait for _refresh_worlds()'s awaited request to complete.
|
||||
## 4. Confirm the screen reached a real end state (world cards, or the
|
||||
## empty-list message -- either is fine, a crash/stuck-loading isn't).
|
||||
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||
## that the rendered card has a real width -- not just non-zero
|
||||
## child_count. 2026-08-20: a real bug shipped where every card
|
||||
## technically existed in the tree (child_count > 0, no error, no
|
||||
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||
## child-count check alone would never catch that, only a real size
|
||||
## assertion does.
|
||||
##
|
||||
## Exits 0 on full pass, 1 on any failure.
|
||||
|
||||
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
@@ -52,6 +64,19 @@ func _run() -> void:
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||
print("\n=== Setup: seed a real world ===")
|
||||
var world_body := JSON.stringify({
|
||||
"name": "Smoke Test World %d" % unique_id,
|
||||
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||
"visibility": "public",
|
||||
})
|
||||
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||
if not _check("setup: world created", create_result.ok):
|
||||
print(" detail: ", create_result.detail)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# --- Test 1: Instantiate the real scene ---
|
||||
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||
@@ -99,10 +124,31 @@ func _run() -> void:
|
||||
|
||||
if error_label.visible:
|
||||
printerr(" Note: ended in error state: ", error_label.text)
|
||||
elif world_list_node.visible:
|
||||
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
|
||||
elif empty_label.visible:
|
||||
print(" Note: ended in empty-list state")
|
||||
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||
# isn't actually seeing what POST /api/worlds/ just created.
|
||||
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||
failed += 1
|
||||
elif world_list_node.visible:
|
||||
var card_count := world_list_node.get_child_count()
|
||||
print(" Note: rendered %d world card(s)" % card_count)
|
||||
|
||||
if _check("at least one card rendered", card_count > 0):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# The actual bug this test exists to catch: a card can be present
|
||||
# in the tree, not crash, and still be visually nothing because
|
||||
# it rendered at ~1px wide. Check every card's real computed size,
|
||||
# not just that it exists.
|
||||
for i in range(card_count):
|
||||
var card: Control = world_list_node.get_child(i)
|
||||
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# --- Summary ---
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
|
||||
+19
-12
@@ -88,10 +88,26 @@ func _refresh_worlds() -> void:
|
||||
|
||||
## Create a visual card for a single world entry.
|
||||
func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
# PanelContainer for the background, not a ColorRect moved to child index
|
||||
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||
# vertical sequence, it has no concept of "float this one child behind
|
||||
# the others as an overlay". That version rendered as an empty dark bar
|
||||
# sitting above the text, not a background behind it.
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.custom_minimum_size = Vector2(0, 80)
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_bottom = 8
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
|
||||
var card := VBoxContainer.new()
|
||||
card.size_flags_horizontal = Control.SIZE_FILL
|
||||
card.custom_minimum_size = Vector2(0, 80)
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card.add_theme_constant_override("separation", 4)
|
||||
panel.add_child(card)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = world_dict.get("name", "Unnamed World")
|
||||
@@ -123,13 +139,4 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
card.add_child(desc_label)
|
||||
card.add_child(meta_label)
|
||||
|
||||
# Subtle background for the card
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color(0.15, 0.15, 0.15, 1.0)
|
||||
bg.size_flags_horizontal = Control.SIZE_FILL
|
||||
bg.size_flags_vertical = Control.SIZE_FILL
|
||||
bg.custom_minimum_size = Vector2(0, 80)
|
||||
card.add_child(bg)
|
||||
card.move_child(bg, 0)
|
||||
|
||||
return card
|
||||
return panel
|
||||
|
||||
Reference in New Issue
Block a user