Compare commits

...

15 Commits

Author SHA1 Message Date
claude-checkpoint-tooling 3a7b2ab306 Add PC1 checkpoint record + playtest instructions
Validation results from the 2026-08-20 checkpoint run (fresh clone,
commit 927f9bd: migrations, 68/68 pytest, all 4 Godot smoke tests, both
platform exports) plus manual playtest instructions for backend on
cloudbison / client on pinkbison. First of docs/checkpoints/PC<N>.md,
one per Package Checkpoint going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 01:17:18 +00:00
reviewer-bot 927f9bd95e Merge pull request 'End-to-end headless smoke test: register -> login -> fetch worlds' (#39) from agent/775379a9bedc into main 2026-08-20 00:26:58 +00:00
reviewer-bot 2d1f0111c3 Merge pull request 'Write auth endpoint tests (pytest)' (#38) from agent/daf820da4497 into main 2026-08-19 23:33:04 +00:00
tester-bot fcfb767c49 End-to-end headless smoke test: register -> login -> fetch worlds
Tester had already written a complete, correct test (4/4 steps pass
against a real backend) but stalled investigating DB/migration setup
after its own verification run silently exited with no result -- the
task spec's suggested invocation included --quit, which kills a --script
SceneTree test before its awaited HTTP calls finish (same Godot
--script-mode gotcha documented elsewhere this session).

Renamed smoke_test.gd -> smoke_test_e2e.gd: the task spec named it
without the checkpoint runner's expected smoke_test_*.gd suffix, so
scripts/run_checkpoint_tests.py's glob would have silently never picked
it up. Also fixed the --quit flag in the file's own usage comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:30:52 +00:00
tester-bot e2e771d2f8 WIP checkpoint: End-to-end headless smoke test + manual verify 2026-08-19 23:15:22 +00:00
reviewer-bot 8a5d3aabea Merge pull request 'Wire browse-worlds screen to GET /api/worlds' (#36) from agent/1de295a0da42 into main 2026-08-19 23:12:27 +00:00
coder-bot 3dd5416e81 Merge origin/main into agent/1de295a0da42
Resolves conflict in main_menu.gd's play button: kept this branch's real
world_list.tscn transition over main's stale TODO placeholder (main's
version predates this fix; nothing else has touched the play button
since). Brings in PR #37's /auth/refresh endpoint and its tests cleanly,
no other conflicts.
2026-08-19 23:11:05 +00:00
tester-bot c5a5476620 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:50:22 +00:00
tester-bot 06604a24a8 WIP checkpoint: Write auth endpoint tests (pytest) 2026-08-19 22:49:19 +00:00
reviewer-bot 5c21e5051c Merge pull request 'Implement /auth/refresh endpoint' (#37) from agent/82c3ebd03921 into main 2026-08-19 22:39:42 +00:00
coder-bot b3227f5b78 Wire browse-worlds screen to GET /api/worlds
- main_menu.gd: Play button now navigates to world_list.tscn
- world_list.gd: fix invalid theme_override_constants property-path syntax
  (use add_theme_constant_override), fix @onready node paths missing
  /HeaderHBox/, cast Variant dict values to String to avoid type-inference
  errors when cards actually render
- world_list.tscn: wire up Back/Refresh button signal connections (scene
  had zero [connection] blocks, buttons were dead)
- backend: fix real bug where World.visibility enum sent member names
  ("PUBLIC") to Postgres instead of lowercase values, breaking both GET
  and POST /api/worlds/ for any real query
- backend: fix WorldResponse.created_at typed as str instead of datetime,
  causing a second 500 on GET once the enum bug was fixed
- add smoke_test_world_list.gd: headless scene-level smoke test that
  registers a user, instantiates the real scene, and confirms world cards
  render from live backend data (not just an API-level check)
2026-08-19 21:57:39 +00:00
coder-bot 2ba6bc6b9f WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:27:24 +00:00
coder-bot 03da22aa9e WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 55a65bb7b1 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
coder-bot 62e15d0c87 WIP checkpoint: Implementation: Browse worlds screen wired to GET /api/worlds 2026-08-19 21:20:52 +00:00
10 changed files with 785 additions and 4 deletions
+6 -1
View File
@@ -21,7 +21,12 @@ class World(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
visibility = Column( visibility = Column(
ENUM(WorldVisibility, name="world_visibility", create_type=False), ENUM(
WorldVisibility,
name="world_visibility",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
default=WorldVisibility.PUBLIC, default=WorldVisibility.PUBLIC,
) )
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+2 -1
View File
@@ -1,5 +1,6 @@
"""Worlds router - CRUD for game worlds.""" """Worlds router - CRUD for game worlds."""
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -40,7 +41,7 @@ class WorldResponse(BaseModel):
description: str | None description: str | None
visibility: WorldVisibility visibility: WorldVisibility
owner_id: int owner_id: int
created_at: str | None = None created_at: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+38
View File
@@ -0,0 +1,38 @@
"""Shared pytest fixtures.
The auth integration tests hit the *real* Postgres database (per
``DATABASE_URL``), so they persist data between runs. Without cleanup a
second run of the suite fails with 409 conflicts because the users it
registered on the first run already exist. The session-scoped fixture below
truncates all application tables once before the first test, making the suite
re-runnable.
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
keys, but the tables are listed explicitly for clarity.
"""
import asyncio
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
TEST_TABLES = ("assets", "worlds", "sessions", "refresh_tokens", "users")
@pytest.fixture(scope="session", autouse=True)
def _reset_database():
"""Truncate all application tables once per test session."""
async def _truncate() -> None:
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.execute(
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
)
await engine.dispose()
asyncio.run(_truncate())
yield
+171
View File
@@ -0,0 +1,171 @@
"""Tests for expired-token rejection across auth endpoints.
Covers two distinct expiry paths:
1. An *access* JWT whose ``exp`` claim is in the past must be rejected
with 401 by ``GET /api/auth/me`` (jose's built-in ``exp`` validation).
2. A *refresh* token whose persisted ``expires_at`` is in the past must be
rejected with 401 by ``POST /api/auth/refresh`` even though its JWT
signature is still valid.
Requires migrations already applied (alembic upgrade head) before running.
"""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from jose import jwt
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.database import get_db
from app.models.session import RefreshToken
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test."""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mint_access_token(exp: datetime) -> str:
"""Mint an RS256 access token with an explicit ``exp`` claim."""
return jwt.encode(
{"sub": "1", "exp": exp},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
def _expire_refresh_token_in_db(token: str) -> None:
"""Set the persisted refresh token's ``expires_at`` into the past."""
async def _do():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
from sqlalchemy import update
await session.execute(
update(RefreshToken)
.where(RefreshToken.token == token)
.values(expires_at=datetime.now(timezone.utc) - timedelta(hours=1))
)
await session.commit()
await engine.dispose()
asyncio.run(_do())
def _register_user(client: TestClient, username: str) -> str:
"""Register a user and return its refresh token."""
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "2000-01-01",
},
)
assert resp.status_code == 201
return resp.json()["refresh_token"]
# ---------------------------------------------------------------------------
# Expired access token (GET /api/auth/me)
# ---------------------------------------------------------------------------
class TestExpiredAccessToken:
def test_expired_access_token_is_rejected_by_me(self):
"""An access JWT with a past exp claim returns 401 on /me."""
client = TestClient(app)
expired_token = _mint_access_token(
datetime.now(timezone.utc) - timedelta(minutes=5)
)
resp = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {expired_token}"},
)
assert resp.status_code == 401
assert "Could not validate credentials" in resp.json()["detail"]
def test_garbage_access_token_is_rejected_by_me(self):
"""A non-JWT bearer token returns 401 on /me."""
client = TestClient(app)
resp = client.get(
"/api/auth/me",
headers={"Authorization": "Bearer not-a-real-jwt"},
)
assert resp.status_code == 401
def test_missing_bearer_token_returns_401(self):
"""No Authorization header at all returns 401 on /me."""
client = TestClient(app)
resp = client.get("/api/auth/me")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Expired refresh token (POST /api/auth/refresh)
# ---------------------------------------------------------------------------
class TestExpiredRefreshToken:
def test_expired_refresh_token_is_rejected(self):
"""A refresh token whose DB expires_at is in the past returns 401."""
client = TestClient(app)
refresh_token = _register_user(client, "expired_rt_user")
# Force the persisted record to be expired while its JWT signature
# is still valid.
_expire_refresh_token_in_db(refresh_token)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
def test_expired_refresh_token_jwt_is_rejected(self):
"""A refresh JWT with a past exp claim is rejected before DB lookup."""
client = TestClient(app)
expired_jwt = jwt.encode(
{"sub": "1", "exp": datetime.now(timezone.utc) - timedelta(minutes=5)},
settings.private_pem,
algorithm=settings.ALGORITHM,
)
resp = client.post(
"/api/auth/refresh",
json={"refresh_token": expired_jwt},
)
assert resp.status_code == 401
assert "Invalid or expired refresh token" in resp.json()["detail"]
+84
View File
@@ -0,0 +1,84 @@
# 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
Cloudbison's LAN IP: `192.168.58.138`. Pinkbison's: `192.168.58.124`.
### 1. On cloudbison — start a real (persistent, not throwaway) backend
```bash
cd ~
git clone http://localhost:3000/daddy/agp-game.git agp-game-playtest
cd agp-game-playtest/backend
docker compose up -d postgres
docker compose exec -T postgres pg_isready -U agp_user -d agp_db # wait for this to succeed
export DATABASE_URL="postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
export JWT_ALGORITHM="RS256"
/home/daddy/agp/venv/bin/python3 -m alembic upgrade head
nohup /home/daddy/agp/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/agp-backend.log 2>&1 &
disown
```
`--host 0.0.0.0` matters — the default `127.0.0.1` only listens on loopback
and pinkbison won't be able to reach it. Verify: `curl
http://localhost:8000/api/health` should return `{"status":"ok",...}`.
### 2. On pinkbison — get the game and point it at cloudbison
Copy `export/windows/agp-game.exe` (or `export/linux/agp-game.x86_64`,
whichever matches pinkbison's OS) from cloudbison, or clone the repo there
and run `scripts/build.sh` locally. Launch with the backend URL as an env
var so you don't have to rebuild to change it:
- Windows (PowerShell): `$env:AGP_API_BASE_URL="http://192.168.58.138:8000"; .\agp-game.exe`
- Linux: `AGP_API_BASE_URL=http://192.168.58.138:8000 ./agp-game.x86_64`
### 3. Tear down when done
```bash
# on cloudbison
pkill -f "uvicorn app.main:app"
cd ~/agp-game-playtest/backend && docker compose down -v
```
## 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.
+76
View File
@@ -0,0 +1,76 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/world_list.gd" id="1_wl"]
[node name="WorldList" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_wl")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 3
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
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "< Back"
size_flags_horizontal = 0
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "Browse Worlds"
horizontal_alignment = 1
size_flags_horizontal = 4
theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
layout_mode = 3
text = "Refresh"
size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_vertical = 4
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
theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = "Loading worlds..."
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[node name="ErrorLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = ""
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[node name="EmptyLabel" type="Label" parent="VBoxContainer"]
layout_mode = 3
text = ""
visible = false
horizontal_alignment = 1
theme_override_font_sizes/font_size = 18
[connection signal="pressed" from="VBoxContainer/HeaderHBox/BackButton" to="." method="_on_back_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
+3 -2
View File
@@ -61,8 +61,9 @@ func _ready() -> void:
func _on_play_button_pressed() -> void: func _on_play_button_pressed() -> void:
if _play_button.disabled: if _play_button.disabled:
return return
# TODO: transition to the worlds / gameplay scene once it exists. # Browse worlds is the first real screen past login -- actual gameplay
print("Play pressed — transitioning to game") # (joining a world) doesn't exist yet, browsing does.
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _on_quit_button_pressed() -> void: func _on_quit_button_pressed() -> void:
+148
View File
@@ -0,0 +1,148 @@
extends SceneTree
## End-to-end headless smoke test: register -> login -> fetch worlds list.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_e2e.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
## the AGP_API_BASE_URL environment variable).
##
## Steps:
## 1. Health check GET /api/health -> 200, {"status": "ok"}
## 2. Register POST /api/auth/register -> 201, access_token present
## 3. Login POST /api/auth/login -> 200, access_token present
## 4. Worlds list GET /api/worlds -> 200, JSON array
##
## Exits 0 if all steps pass, 1 otherwise. If the backend is unreachable the
## health check fails with a clear message and the script exits 1 immediately.
# Fixed credentials from the task spec. The username gets a unique numeric
# suffix so the test stays re-runnable (a second run would otherwise hit
# "Username already registered" on step 2).
const TEST_USERNAME_BASE := "smoke_test_user"
const TEST_EMAIL := "smoke@test.com"
const TEST_PASSWORD := "TestPass123"
const TEST_DOB := "2000-01-01"
func _initialize() -> void:
_run()
func _run() -> void:
# _initialize() fires before the first frame; wait one frame so freshly
# created HTTPRequest nodes are reliably inside the tree.
await process_frame
# In --script mode the autoload is reachable via get_node(), not as a
# bare identifier (bare identifiers fail to resolve at compile time here).
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found in the scene tree")
var unique_id := int(Time.get_unix_time_from_system() * 1000)
var username := "%s_%d" % [TEST_USERNAME_BASE, unique_id]
var email := "smoke_%d@test.com" % unique_id
var results := {}
# --- Step 1: Health check -------------------------------------------------
print("\n=== Step 1: Health check (GET /api/health) ===")
var health: Dictionary = await api.health_check()
var health_ok: bool = (
health.ok
and health.status == 200
and health.data is Dictionary
and str((health.data as Dictionary).get("status", "")) == "ok"
)
_record(results, "health", health_ok, "status=%d data=%s" % [
health.status, health.data
])
if not health.ok or health.status == 0:
printerr("\nERROR: Backend is not reachable at the configured base URL.")
printerr(" detail: %s" % health.detail)
printerr(" Start the backend first (see backend/README), then re-run:")
printerr(" godot --headless --path . --script res://scripts/smoke_test_e2e.gd")
_summarize(results)
quit(1)
return
# --- Step 2: Register -----------------------------------------------------
print("\n=== Step 2: Register (POST /api/auth/register) ===")
var reg: Dictionary = await api.register(username, email, TEST_PASSWORD, TEST_DOB)
var reg_token := _extract_token(reg)
var reg_ok: bool = (
reg.ok
and reg.status == 201
and reg_token != ""
)
_record(results, "register", reg_ok, "status=%d token=%s detail=%s" % [
reg.status, "yes" if reg_token != "" else "no", reg.detail
])
# --- Step 3: Login --------------------------------------------------------
print("\n=== Step 3: Login (POST /api/auth/login) ===")
var login: Dictionary = await api.login(username, TEST_PASSWORD)
var login_token := _extract_token(login)
var login_ok: bool = (
login.ok
and login.status == 200
and login_token != ""
)
_record(results, "login", login_ok, "status=%d token=%s detail=%s" % [
login.status, "yes" if login_token != "" else "no", login.detail
])
# --- Step 4: Worlds list ---------------------------------------------------
print("\n=== Step 4: Worlds list (GET /api/worlds) ===")
# login/register already stored the token; make the auth header explicit by
# re-applying the extracted token so step 4 is independent of storage order.
if login_token != "":
api.set_token(login_token)
var worlds: Dictionary = await api.get_worlds()
var worlds_ok: bool = (
worlds.ok
and worlds.status == 200
and worlds.data is Array
)
_record(results, "worlds", worlds_ok, "status=%d is_array=%s count=%d detail=%s" % [
worlds.status,
"yes" if (worlds.data is Array) else "no",
(worlds.data as Array).size() if (worlds.data is Array) else -1,
worlds.detail,
])
# --- Summary ---------------------------------------------------------------
_summarize(results)
func _extract_token(result: Dictionary) -> String:
if result.ok and result.data is Dictionary:
return str((result.data as Dictionary).get("access_token", ""))
return ""
func _record(results: Dictionary, name: String, ok: bool, detail: String) -> void:
results[name] = {"ok": ok, "detail": detail}
if ok:
print(" PASS: %s" % detail)
else:
printerr(" FAIL: %s" % detail)
func _summarize(results: Dictionary) -> void:
var all_pass := true
print("\n=== Smoke test summary ===")
for step in ["health", "register", "login", "worlds"]:
var entry: Dictionary = results.get(step, {"ok": false, "detail": "not run"})
if not entry["ok"]:
all_pass = false
print(" %-8s : %s" % [step, "PASS" if entry["ok"] else "FAIL"])
if all_pass:
print("SMOKE RESULT: ALL PASS (4/4)")
quit(0)
else:
printerr("SMOKE RESULT: FAIL")
quit(1)
+122
View File
@@ -0,0 +1,122 @@
extends SceneTree
## Headless smoke test for the world list screen.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_world_list.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, override with
## AGP_API_BASE_URL).
##
## Unlike a pure API-level test, this actually instantiates scenes/
## world_list.tscn as a real scene and adds it to the tree -- catches
## @onready node-path bugs (a node reference pointing at the wrong path
## errors right here, not just "looks right" from reading the script) that
## an API-only test would miss entirely.
##
## 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
## 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).
##
## Exits 0 on full pass, 1 on any failure.
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
assert(api != null, "ApiClient autoload not found")
var passed: int = 0
var failed: int = 0
# --- Setup: register + log in so the world list has a valid token ---
print("\n=== Setup: register + login ===")
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "worldlist_smoke_%d" % unique_id
var email := "worldlist_smoke_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", "2000-01-01")
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_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)
if not _check("scene loads", packed_scene != null):
quit(1)
return
var instance: Control = packed_scene.instantiate()
if not _check("scene instantiates", instance != null):
quit(1)
return
root.add_child(instance)
# One frame for _ready() (and its @onready resolution) to run. A wrong
# node path in an @onready var errors during this, not silently.
await process_frame
if _check("scene added to tree without error", is_instance_valid(instance)):
passed += 1
else:
failed += 1
# --- Test 2: Wait for the world fetch to resolve, check a real end state ---
print("\n=== Test 2: World fetch reaches a real end state ===")
# _refresh_worlds() runs from _ready(); give its awaited HTTP call time
# to complete rather than a fixed sleep -- poll for either end state.
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
var empty_label: Label = instance.get_node("VBoxContainer/EmptyLabel")
var error_label: Label = instance.get_node("VBoxContainer/ErrorLabel")
var settled := false
for i in range(50): # up to ~5s at 100ms/frame-ish via process_frame waits
await process_frame
if world_list_node.visible or empty_label.visible or error_label.visible:
settled = true
break
if _check("reached a real end state (cards, empty, or error) not stuck loading", settled):
passed += 1
else:
failed += 1
print(" world_list.visible=%s empty_label.visible=%s error_label.visible=%s" % [
world_list_node.visible, empty_label.visible, error_label.visible
])
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")
# --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
if failed > 0:
quit(1)
else:
print("SMOKE RESULT: ALL PASS")
quit(0)
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
return true
else:
printerr(" FAIL: ", description)
return false
+135
View File
@@ -0,0 +1,135 @@
extends Control
## World list screen — browses public worlds via GET /api/worlds.
##
## Displays a scrollable list of world cards (name + description).
## Handles loading, empty list, errors, and refresh.
# UI references
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
@onready var error_label: Label = $VBoxContainer/ErrorLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
func _ready() -> void:
refresh_button.disabled = false
back_button.disabled = false
loading_label.visible = false
error_label.visible = false
empty_label.visible = false
world_list.visible = false
_refresh_worlds()
func _on_refresh_button_pressed() -> void:
_refresh_worlds()
func _on_back_button_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
## Fetch the world list from the backend and update the UI.
func _refresh_worlds() -> void:
# Reset UI state
loading_label.visible = true
error_label.visible = false
empty_label.visible = false
world_list.visible = false
refresh_button.disabled = true
# Clear previous world cards
for child in world_list.get_children():
child.queue_free()
var result := await ApiClient.get_worlds()
loading_label.visible = false
refresh_button.disabled = false
if not result.ok:
# Show error message (401, network failure, etc.)
error_label.text = "Error: %s" % result.detail
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
var data = result.data
if not data is Array:
error_label.text = "Unexpected response format."
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
if data.is_empty():
empty_label.text = "No worlds yet. Check back later!"
empty_label.visible = true
error_label.visible = false
world_list.visible = false
return
# Render world cards
world_list.visible = true
empty_label.visible = false
error_label.visible = false
for world_dict in data:
if not world_dict is Dictionary:
continue
var card := _create_world_card(world_dict)
world_list.add_child(card)
## Create a visual card for a single world entry.
func _create_world_card(world_dict: Dictionary) -> Control:
var card := VBoxContainer.new()
card.size_flags_horizontal = Control.SIZE_FILL
card.custom_minimum_size = Vector2(0, 80)
card.add_theme_constant_override("separation", 4)
var name_label := Label.new()
name_label.text = world_dict.get("name", "Unnamed World")
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
name_label.size_flags_horizontal = Control.SIZE_FILL
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
name_label.add_theme_font_size_override("font_size", 18)
var desc_label := Label.new()
var description: String = str(world_dict.get("description", ""))
desc_label.text = description if description != "" else "No description."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.size_flags_horizontal = Control.SIZE_FILL
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
desc_label.add_theme_font_size_override("font_size", 14)
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
var meta_label := Label.new()
var visibility: String = str(world_dict.get("visibility", "public"))
var created: String = str(world_dict.get("created_at", ""))
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
meta_label.size_flags_horizontal = Control.SIZE_FILL
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
meta_label.add_theme_font_size_override("font_size", 12)
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
card.add_child(name_label)
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