Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b0c3f1fb | |||
| dc0c133af8 | |||
| 8f1a1110e2 | |||
| 12fef51020 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| c5a5476620 | |||
| 06604a24a8 |
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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,72 +7,98 @@ to know what "run the tests" actually checked.
|
|||||||
|
|
||||||
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
|
||||||
|
|
||||||
Last verified against `main`: 2026-08-19 (commit `1c75367`).
|
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)
|
## Backend — `backend/tests/` (pytest)
|
||||||
|
|
||||||
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
Run: `cd backend && python -m pytest -v` (needs `DATABASE_URL` pointed at a
|
||||||
reachable Postgres — see the runner script; most of these tests don't
|
reachable Postgres — see the runner script).
|
||||||
actually touch the DB, but the app's import chain requires a valid URL to
|
|
||||||
construct).
|
|
||||||
|
|
||||||
**Current total: 38 tests, all passing on a clean `main` checkout.**
|
**Current total: 68 tests, all passing on a clean `main` checkout.**
|
||||||
|
|
||||||
| File | Tests | What it covers |
|
| File | Tests | What it covers |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test_auth_age_gate.py` | 10 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
| `test_auth_age_gate.py` | 17 | `calculate_age()` date-math correctness (exact boundary, leap years, birthday-not-yet-this-year) + `RegisterRequest` date-of-birth validation (parametrized valid/invalid date rejection). Backs the 18+ registration gate (PR #23). |
|
||||||
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
| `test_gltf_metadata.py` | 21 | glTF/GLB binary + JSON parsing: magic-byte detection, chunk-padding handling, mesh/animation/texture-reference metadata extraction, malformed-input rejection. Backs the asset-import pipeline (PR #27). |
|
||||||
|
| `test_auth_endpoints.py` | 11 | **Integration tests** for `POST /api/auth/register` and `POST /api/auth/login` against the real FastAPI app + real Postgres. Covers: successful register (201), duplicate username (409), duplicate email (409), underage rejection (400), invalid date format (422), exact-18 registration, successful login with correct credentials (200), wrong password (401), nonexistent user (401), form-encoded login (OAuth2), and JWT validity of returned tokens. |
|
||||||
|
| `test_refresh_endpoint.py` | 8 | **Integration tests** for `POST /api/auth/refresh`. Covers: successful refresh (200, new access token, same refresh token returned), refresh produces different access tokens on repeated calls, invalid refresh token (401), empty body (422), missing field (422), refresh token from login also works, returned access token is valid JWT decodable with public key, `access_token_expires_minutes` in response. |
|
||||||
|
| `test_refresh_token_jti.py` | 6 | Unit tests for refresh token uniqueness (jti claim) and model column length. Covers: two calls with same sub produce different tokens (jti uniqueness), jti is valid UUID, rapid repeated calls all produce unique jtis, jti survives decode, `RefreshToken.token` column is `String(512)`, column type is `String`. |
|
||||||
|
| `test_auth_expired_token.py` | 5 | **Integration tests** for expired-token rejection across auth endpoints. Covers: expired access JWT rejected by `GET /api/auth/me` (401), garbage access token rejected by `/me` (401), missing bearer token returns 401, expired refresh token (DB `expires_at` in past) rejected by `/refresh` (401), expired refresh JWT (past `exp` claim) rejected before DB lookup (401). |
|
||||||
|
|
||||||
**What these are, honestly:** pure unit tests of standalone functions
|
**What these are, honestly:** a mix of pure unit tests (`test_auth_age_gate.py`,
|
||||||
(`calculate_age`, the glTF parser, Pydantic validators). None of them spin
|
`test_gltf_metadata.py`, `test_refresh_token_jti.py`) and real integration tests
|
||||||
up the FastAPI app or hit a real endpoint — they run in ~1s and don't
|
(`test_auth_endpoints.py`, `test_refresh_endpoint.py`, `test_auth_expired_token.py`)
|
||||||
actually need Postgres reachable at all (the import chain just needs a
|
that hit the FastAPI app against a real Postgres database via `TestClient`.
|
||||||
syntactically valid `DATABASE_URL`, nothing connects during collection).
|
The integration tests exercise the full stack: Pydantic validation → DB →
|
||||||
|
JWT → response. The suite is also re-runnable without manual cleanup —
|
||||||
|
`backend/tests/conftest.py` has a session-scoped autouse fixture that
|
||||||
|
truncates all app tables once before the suite runs.
|
||||||
|
|
||||||
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
**Known coverage gaps** (real gaps, not placeholders — nothing enforces filling
|
||||||
these yet):
|
these yet):
|
||||||
- No integration/endpoint tests at all — `/register`, `/login`, `/refresh`,
|
- No endpoint tests for `GET /api/worlds`, `GET /api/assets/*`, or any
|
||||||
`/worlds`, `/assets/*` have zero tests that actually call them through
|
world/asset CRUD routes — only auth endpoints have integration coverage
|
||||||
the FastAPI app (e.g. via `httpx.AsyncClient` + `ASGITransport`) against a
|
(`GET /api/worlds` is covered indirectly via the Godot smoke tests below).
|
||||||
real (or test) database. Everything currently shipped works because it
|
- No tests for the moderation schema/migration beyond "the migration runs."
|
||||||
was manually verified at merge time (checkpoint review), not because a
|
- No tests for the `GET /api/auth/me` endpoint's response body (only its
|
||||||
test asserts it.
|
auth-header validation is covered via expired-token tests).
|
||||||
- No tests for the RS256 auth upgrade (key generation, refresh-token
|
- No tests for session management (create/validate/revoke sessions).
|
||||||
issuance/persistence) — PR #30, pending review as of this writing,
|
|
||||||
manually verified (correct key usage, all 38 existing tests still pass)
|
|
||||||
but not covered by an automated test of its own yet.
|
|
||||||
- No tests for the moderation schema/migration (in progress as of this
|
|
||||||
writing) beyond "the migration runs."
|
|
||||||
|
|
||||||
## Godot client — `scripts/smoke_test_*.gd`
|
## Godot client — `scripts/smoke_test_*.gd`
|
||||||
|
|
||||||
**Nothing merged into `main` yet as of this writing** — `sandbox_probe/test_script.gd`
|
**6 smoke tests merged into `main`.** Each needs a live backend —
|
||||||
is the only Godot script on `main` right now, and it's **not a real
|
`AGP_API_BASE_URL` env var, falling back to the
|
||||||
test** (a leftover manual probe, returns a hardcoded string, asserts
|
`application/agp/api_base_url` project setting (default
|
||||||
nothing). Don't count it toward coverage; delete or repurpose it if anyone
|
`http://127.0.0.1:8000`) if unset.
|
||||||
actually needs a sandbox probe again.
|
|
||||||
|
|
||||||
`scripts/smoke_test_api.gd` (PC1, PR #29, pending review as of this writing)
|
| File | What it covers |
|
||||||
will be the first real one once merged — `ApiClient` autoload's
|
|---|---|
|
||||||
`health_check()` against a **live backend** (set `AGP_API_BASE_URL` to point
|
| `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. |
|
||||||
at one), verified by hand end-to-end before opening the PR: exercises the
|
| `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). |
|
||||||
real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, the
|
| `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. |
|
||||||
result-dictionary contract), not just that the script compiles. Exits 1 on
|
| `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. |
|
||||||
failure, so it's CI/script-friendly.
|
| `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 merged): `godot --headless --path . --import` once (builds the
|
Run (once, to build the class cache): `godot --headless --path . --import`,
|
||||||
class cache — skip this and every `class_name`-declared class falsely
|
then for each smoke test: `godot --headless --path . --script
|
||||||
errors as undeclared), then for each `scripts/smoke_test_*.gd`:
|
res://scripts/smoke_test_<name>.gd` — **no `--quit`**, it kills these before
|
||||||
`godot --headless --path . --script res://scripts/<name>.gd`
|
their awaited HTTP calls finish.
|
||||||
|
|
||||||
**Known gap once it lands:** only `health_check()` is exercised end-to-end.
|
**Known gaps:**
|
||||||
`login`, `register`, `get_worlds`, `get_me` are implemented (see
|
- No smoke tests for chunk loading/streaming (`chunk.gd`, `chunk_scene.gd`).
|
||||||
`scripts/api_client.gd`) but have no smoke coverage yet — each would need a
|
- No smoke tests for dynamic/static object nodes.
|
||||||
real user/world fixture in the target backend to test against meaningfully.
|
- All smoke tests require a live backend; no offline/unit smoke tests exist.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
`scripts/build.sh` produces real Linux (`agp-game.x86_64`) and Windows
|
||||||
|
(`agp-game.exe`) binaries via `--export-release`. Verified 2026-08-20: both
|
||||||
|
exports complete clean from a fresh checkout and produce valid binaries
|
||||||
|
(confirmed via `file` — real ELF64/PE32+, not empty or truncated). Neither
|
||||||
|
export is exercised by an automated GUI test (headless Godot can't drive a
|
||||||
|
window) — actually launching and clicking through the exported binary is a
|
||||||
|
manual step, see `docs/checkpoints/PC1.md` for playtest instructions.
|
||||||
|
|
||||||
## What "full coverage" should eventually mean here
|
## What "full coverage" should eventually mean here
|
||||||
|
|
||||||
Not defined precisely yet — the honest target as of 2026-08-19 is just
|
Not defined precisely yet — the honest target as of 2026-08-20 is
|
||||||
"more than pure-function unit tests." As real integration/endpoint tests get
|
"integration tests for every endpoint + smoke tests for every major scene."
|
||||||
added, update the tables above rather than letting this doc drift stale;
|
As real integration/endpoint tests get added, update the tables above
|
||||||
a wrong inventory is worse than no inventory.
|
rather than letting this doc drift stale; a wrong inventory is worse than
|
||||||
|
no inventory.
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
@@ -52,7 +52,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/bptc=true
|
texture_format/bptc=true
|
||||||
texture_format/s3tc=true
|
texture_format/s3tc=true
|
||||||
texture_format/etc=false
|
texture_format/etc=false
|
||||||
|
|||||||
+17
-5
@@ -29,6 +29,21 @@ text = "AGP Game"
|
|||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
theme_override_font_sizes/font_size = 72
|
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"]
|
[node name="AuthPanel" type="PanelContainer" parent="CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
visible = false
|
visible = false
|
||||||
@@ -74,10 +89,6 @@ visible = false
|
|||||||
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
placeholder_text = "Date of Birth (YYYY-MM-DD)"
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
[node name="ModeToggle" type="CheckButton" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "Register"
|
|
||||||
|
|
||||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
text = "Login"
|
text = "Login"
|
||||||
@@ -106,9 +117,10 @@ layout_mode = 1
|
|||||||
text = "Quit"
|
text = "Quit"
|
||||||
size_flags_horizontal = 4
|
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/PlayButton" to="." method="_on_play_button_pressed"]
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_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"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
[connection signal="pressed" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit" to="." method="_on_auth_button_pressed"]
|
||||||
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit" to="." method="_on_auth_button_pressed"]
|
||||||
|
|||||||
+11
-9
@@ -11,14 +11,17 @@ grow_vertical = 2
|
|||||||
script = ExtResource("1_wl")
|
script = ExtResource("1_wl")
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
[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
|
theme_override_constants/separation = 8
|
||||||
size_flags_horizontal = 4
|
|
||||||
size_flags_vertical = 4
|
|
||||||
|
|
||||||
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
theme_override_constants/separation = 16
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
|
|||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
text = "Browse Worlds"
|
text = "Browse Worlds"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
theme_override_font_sizes/font_size = 28
|
theme_override_font_sizes/font_size = 28
|
||||||
|
|
||||||
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
|
||||||
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
|
|||||||
|
|
||||||
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 4
|
size_flags_vertical = 3
|
||||||
horizontal_mode = 2
|
horizontal_mode = 2
|
||||||
vertical_mode = 2
|
vertical_mode = 2
|
||||||
|
|
||||||
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
size_flags_horizontal = 4
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 4
|
|
||||||
theme_override_constants/separation = 8
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
|
||||||
|
|||||||
+37
-30
@@ -1,24 +1,27 @@
|
|||||||
extends Control
|
extends Control
|
||||||
## Main menu with login/register UI wired to the backend auth API.
|
## 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 AuthPanel is hidden by default. When the user clicks the LoginButton or
|
||||||
## (the existing Play/Quit buttons), the auth panel slides in. The ModeToggle
|
## RegisterButton (AuthButtonsRow, above the panel), the auth panel slides in.
|
||||||
## switches between Login and Register modes, showing/hiding the email and DOB
|
## The Login and Register buttons on the AuthButtonsRow directly set the panel's
|
||||||
## fields accordingly.
|
## mode, so the user can switch between Login and Register at any time while
|
||||||
|
## the panel is open.
|
||||||
##
|
##
|
||||||
## On successful auth the token is stored via ApiClient, the welcome label
|
## On successful auth the token is stored via ApiClient, AuthButtonsRow hides,
|
||||||
## shows the username, and the Play button becomes enabled.
|
## the welcome label shows the username, and the Play button becomes enabled.
|
||||||
|
## The auth form fields are hidden, leaving only the welcome message visible.
|
||||||
|
|
||||||
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── UI references ────────────────────────────────────────────────────────────
|
var _auth_buttons_row: HBoxContainer
|
||||||
|
var _login_button: Button
|
||||||
|
var _register_button: Button
|
||||||
var _auth_panel: PanelContainer
|
var _auth_panel: PanelContainer
|
||||||
var _auth_mode_label: Label
|
var _auth_mode_label: Label
|
||||||
var _username_edit: LineEdit
|
var _username_edit: LineEdit
|
||||||
var _password_edit: LineEdit
|
var _password_edit: LineEdit
|
||||||
var _email_edit: LineEdit
|
var _email_edit: LineEdit
|
||||||
var _dob_edit: LineEdit
|
var _dob_edit: LineEdit
|
||||||
var _mode_toggle: CheckButton
|
|
||||||
var _auth_button: Button
|
var _auth_button: Button
|
||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _welcome_label: Label
|
var _welcome_label: Label
|
||||||
@@ -29,17 +32,19 @@ var _is_register_mode: bool = false
|
|||||||
var _is_authenticating: bool = false
|
var _is_authenticating: bool = false
|
||||||
|
|
||||||
|
|
||||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Cache node references.
|
# 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_panel = $CenterContainer/VBoxContainer/AuthPanel
|
||||||
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
_auth_mode_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel
|
||||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit
|
||||||
_mode_toggle = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle
|
|
||||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||||
@@ -56,7 +61,7 @@ func _ready() -> void:
|
|||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
|
|
||||||
|
|
||||||
# ── Play / Quit ──────────────────────────────────────────────────────────────
|
# ─── Play / Quit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_play_button_pressed() -> void:
|
func _on_play_button_pressed() -> void:
|
||||||
if _play_button.disabled:
|
if _play_button.disabled:
|
||||||
@@ -70,29 +75,24 @@ func _on_quit_button_pressed() -> void:
|
|||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
|
|
||||||
|
|
||||||
# ── Auth panel toggle ────────────────────────────────────────────────────────
|
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_login_button_pressed() -> void:
|
func _on_login_button_pressed() -> void:
|
||||||
"""Show the auth panel (called from a future Login button on the main menu)."""
|
"""Show the auth panel in login mode, called from AuthButtonsRow/LoginButton."""
|
||||||
_auth_panel.visible = true
|
_auth_panel.visible = true
|
||||||
_auth_panel.grab_focus()
|
_is_register_mode = false
|
||||||
|
_update_auth_mode_ui()
|
||||||
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _on_register_button_pressed() -> void:
|
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
|
_auth_panel.visible = true
|
||||||
_is_register_mode = true
|
_is_register_mode = true
|
||||||
_mode_toggle.button_pressed = true
|
|
||||||
_update_auth_mode_ui()
|
|
||||||
_clear_status()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Mode toggle (Login / Register) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func _on_mode_toggle_toggled(button_pressed: bool) -> void:
|
|
||||||
_is_register_mode = button_pressed
|
|
||||||
_update_auth_mode_ui()
|
_update_auth_mode_ui()
|
||||||
_clear_status()
|
_clear_status()
|
||||||
|
_username_edit.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _update_auth_mode_ui() -> void:
|
func _update_auth_mode_ui() -> void:
|
||||||
@@ -109,7 +109,7 @@ func _update_auth_mode_ui() -> void:
|
|||||||
_dob_edit.visible = false
|
_dob_edit.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Auth submission ──────────────────────────────────────────────────────────
|
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _on_auth_button_pressed() -> void:
|
func _on_auth_button_pressed() -> void:
|
||||||
"""Handle login or register button press."""
|
"""Handle login or register button press."""
|
||||||
@@ -166,18 +166,25 @@ func _on_auth_complete(result: Dictionary) -> void:
|
|||||||
_is_authenticating = false
|
_is_authenticating = false
|
||||||
|
|
||||||
if result.ok:
|
if result.ok:
|
||||||
# Success — show welcome, enable Play.
|
# Success — hide the auth form fields, show only the welcome label.
|
||||||
|
_username_edit.visible = false
|
||||||
|
_password_edit.visible = false
|
||||||
|
_email_edit.visible = false
|
||||||
|
_dob_edit.visible = false
|
||||||
|
_auth_button.visible = false
|
||||||
|
_auth_mode_label.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
|
||||||
_play_button.disabled = false
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
print("Auth successful. Token stored.")
|
print("Auth successful. Token stored.")
|
||||||
else:
|
else:
|
||||||
# Failure — show backend error detail.
|
# Failure — show backend error detail.
|
||||||
_show_status(result.detail)
|
_show_status(result.detail)
|
||||||
|
|
||||||
|
|
||||||
# ── Status helpers ───────────────────────────────────────────────────────────
|
# ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _show_status(message: String) -> void:
|
func _show_status(message: String) -> void:
|
||||||
_status_label.text = message
|
_status_label.text = message
|
||||||
@@ -189,7 +196,7 @@ func _clear_status() -> void:
|
|||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ───────────────────────────────────────────────────────────────
|
# ─── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _is_valid_date(date_str: String) -> bool:
|
func _is_valid_date(date_str: String) -> bool:
|
||||||
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
"""Check if a string looks like a valid YYYY-MM-DD date."""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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. Also verifies that after successful auth the auth
|
||||||
|
## form fields are hidden, leaving only the welcome message visible.
|
||||||
|
|
||||||
|
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 auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
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", welcome_label.visible)
|
||||||
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
# Verify the auth form fields are hidden after successful auth,
|
||||||
|
# leaving only the welcome message visible within the panel.
|
||||||
|
_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("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_"))
|
||||||
|
|
||||||
|
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,126 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Verifies the main menu's Login/Register buttons correctly open the auth
|
||||||
|
## panel and toggle between Login and Register modes via signal emission.
|
||||||
|
##
|
||||||
|
## Key tests:
|
||||||
|
## - AuthPanel starts hidden.
|
||||||
|
## - LoginButton press opens the panel in Login mode.
|
||||||
|
## - RegisterButton press opens the panel in Register mode.
|
||||||
|
## - Clicking Login while in Register mode switches back to Login mode.
|
||||||
|
## - Clicking Register while in Login mode switches to Register mode.
|
||||||
|
## - The ModeToggle CheckButton no longer exists in the scene.
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
var auth_mode_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthModeLabel")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# --- Test 1: auth panel starts hidden ---
|
||||||
|
if _check("auth panel starts hidden", not auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 2: LoginButton press opens panel in Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel visible after LoginButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Login mode label after LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden in Login mode", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field hidden in Login mode", not dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 3: RegisterButton press switches to Register mode ---
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("auth panel still visible after RegisterButton press", auth_panel.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Register mode label after RegisterButton press", auth_mode_label.text == "Register"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field visible in Register mode", email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field visible in Register mode", dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 4: LoginButton press switches back to Login mode ---
|
||||||
|
login_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if _check("Login mode label after second LoginButton press", auth_mode_label.text == "Login"):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("Email field hidden after switching back to Login", not email_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if _check("DOB field hidden after switching back to Login", not dob_edit.visible):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Test 5: ModeToggle CheckButton no longer exists ---
|
||||||
|
var mode_toggle_path = "CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/ModeToggle"
|
||||||
|
var has_mode_toggle = instance.has_node(mode_toggle_path)
|
||||||
|
if _check("ModeToggle CheckButton removed from scene", not has_mode_toggle):
|
||||||
|
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:
|
## Tests:
|
||||||
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
|
## 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.
|
## must not error.
|
||||||
## 3. Wait for _refresh_worlds()'s awaited request to complete.
|
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||||
## 4. Confirm the screen reached a real end state (world cards, or the
|
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||||
## empty-list message -- either is fine, a crash/stuck-loading isn't).
|
## 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.
|
## Exits 0 on full pass, 1 on any failure.
|
||||||
|
|
||||||
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
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:
|
func _initialize() -> void:
|
||||||
@@ -52,6 +64,19 @@ func _run() -> void:
|
|||||||
quit(1)
|
quit(1)
|
||||||
return
|
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 ---
|
# --- Test 1: Instantiate the real scene ---
|
||||||
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||||
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||||
@@ -99,10 +124,31 @@ func _run() -> void:
|
|||||||
|
|
||||||
if error_label.visible:
|
if error_label.visible:
|
||||||
printerr(" Note: ended in error state: ", error_label.text)
|
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:
|
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 ---
|
# --- Summary ---
|
||||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
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.
|
## Create a visual card for a single world entry.
|
||||||
func _create_world_card(world_dict: Dictionary) -> Control:
|
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()
|
var card := VBoxContainer.new()
|
||||||
card.size_flags_horizontal = Control.SIZE_FILL
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
card.custom_minimum_size = Vector2(0, 80)
|
|
||||||
card.add_theme_constant_override("separation", 4)
|
card.add_theme_constant_override("separation", 4)
|
||||||
|
panel.add_child(card)
|
||||||
|
|
||||||
var name_label := Label.new()
|
var name_label := Label.new()
|
||||||
name_label.text = world_dict.get("name", "Unnamed World")
|
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(desc_label)
|
||||||
card.add_child(meta_label)
|
card.add_child(meta_label)
|
||||||
|
|
||||||
# Subtle background for the card
|
return panel
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user