Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb4998b3f | |||
| c287202b9d | |||
| dc0c133af8 | |||
| c01f9de586 | |||
| 12fef51020 | |||
| 0505cd7ff1 | |||
| af50819a16 | |||
| 16bb87c6b0 | |||
| 8fc9d378e4 | |||
| 3679e4dbc4 | |||
| c33b10ac46 | |||
| 8c54129bcd | |||
| 409e78baa5 | |||
| 1cc81979a4 | |||
| f8e5de9293 | |||
| 3a7b2ab306 | |||
| afc2211d70 | |||
| 927f9bd95e | |||
| 2d1f0111c3 | |||
| fcfb767c49 | |||
| e2e771d2f8 | |||
| 8a5d3aabea | |||
| 3dd5416e81 | |||
| c5a5476620 | |||
| 06604a24a8 | |||
| 5c21e5051c | |||
| b3227f5b78 | |||
| 2ba6bc6b9f | |||
| 03da22aa9e | |||
| 55a65bb7b1 | |||
| 62e15d0c87 |
@@ -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())
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[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 = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
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 = 3
|
||||||
|
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 = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_mode = 2
|
||||||
|
vertical_mode = 2
|
||||||
|
|
||||||
|
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
|
||||||
|
layout_mode = 3
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
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"]
|
||||||
+81
-31
@@ -1,24 +1,40 @@
|
|||||||
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.
|
||||||
|
##
|
||||||
|
## When returning from another scene (e.g. world_list), _ready() checks whether
|
||||||
|
## ApiClient already holds a valid token. If so, it restores the authenticated
|
||||||
|
## UI state (Play enabled, welcome label visible) instead of forcing the user
|
||||||
|
## back to the login form.
|
||||||
|
|
||||||
|
|
||||||
# ── UI references ────────────────────────────────────────────────────────────
|
## Emitted once _ready()'s session check (has_token() + the awaited
|
||||||
|
## get_me() call) has actually resolved, whichever way -- lets a test (or
|
||||||
|
## anything else) know the UI state is trustworthy now, rather than
|
||||||
|
## guessing from a frame count or from UI properties that are ambiguous
|
||||||
|
## mid-await (e.g. AuthButtonsRow.visible is true both before the check
|
||||||
|
## resolves AND in a genuine logged-out end state).
|
||||||
|
signal auth_state_ready
|
||||||
|
|
||||||
|
# ─── UI references ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var _auth_buttons_row: HBoxContainer
|
||||||
|
var _login_button: Button
|
||||||
|
var _register_button: Button
|
||||||
var _auth_panel: PanelContainer
|
var _auth_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,24 +45,59 @@ 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
|
||||||
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
_play_button = $CenterContainer/VBoxContainer/PlayButton
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
||||||
|
|
||||||
# Initial state.
|
# Check whether a valid session already exists (e.g. user logged in,
|
||||||
|
# navigated to world_list, then clicked Back). If so, restore the
|
||||||
|
# authenticated UI state instead of forcing the login form again.
|
||||||
|
if ApiClient.has_token():
|
||||||
|
var me_result: Dictionary = await ApiClient.get_me()
|
||||||
|
if me_result.ok and me_result.data is Dictionary:
|
||||||
|
var username: String = str((me_result.data as Dictionary).get("username", ""))
|
||||||
|
if username != "":
|
||||||
|
_show_authenticated(username)
|
||||||
|
auth_state_ready.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Token missing or invalid — fall through to logged-out state.
|
||||||
|
_reset_to_logged_out()
|
||||||
|
auth_state_ready.emit()
|
||||||
|
|
||||||
|
|
||||||
|
## Restore the UI to the authenticated state (Play enabled, welcome label,
|
||||||
|
## auth buttons hidden).
|
||||||
|
func _show_authenticated(username: String) -> void:
|
||||||
|
_auth_panel.visible = false
|
||||||
|
_status_label.visible = false
|
||||||
|
_welcome_label.text = "Welcome, %s!" % username
|
||||||
|
_welcome_label.visible = true
|
||||||
|
_email_edit.visible = false
|
||||||
|
_dob_edit.visible = false
|
||||||
|
_play_button.disabled = false
|
||||||
|
_auth_buttons_row.visible = false
|
||||||
|
print("Restored authenticated session for: ", username)
|
||||||
|
|
||||||
|
|
||||||
|
## Reset the UI to the logged-out state (Play disabled, auth panel hidden,
|
||||||
|
## login/register buttons visible).
|
||||||
|
func _reset_to_logged_out() -> void:
|
||||||
_auth_panel.visible = false
|
_auth_panel.visible = false
|
||||||
_status_label.visible = false
|
_status_label.visible = false
|
||||||
_welcome_label.visible = false
|
_welcome_label.visible = false
|
||||||
@@ -56,42 +107,38 @@ 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:
|
||||||
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:
|
||||||
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:
|
||||||
@@ -108,7 +155,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."""
|
||||||
@@ -165,18 +212,21 @@ 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 — show welcome, enable Play, hide the now-redundant
|
||||||
|
# Login/Register entry points (the auth panel itself stays open,
|
||||||
|
# showing the welcome message in place of the form).
|
||||||
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
_welcome_label.text = "Welcome, %s!" % _username_edit.text.strip_edges()
|
||||||
_welcome_label.visible = true
|
_welcome_label.visible = true
|
||||||
_status_label.visible = false
|
_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
|
||||||
@@ -188,7 +238,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,150 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Regression test for the Play -> Back navigation bug.
|
||||||
|
##
|
||||||
|
## The reported bug: after logging in and clicking Play, the user lands on
|
||||||
|
## world_list.tscn; clicking Back returns to main_menu.tscn, but the menu
|
||||||
|
## reset to a fully logged-out state (Play disabled, login form showing)
|
||||||
|
## even though ApiClient still held a valid token. main_menu.gd's _ready()
|
||||||
|
## used to unconditionally reset the UI on every scene load.
|
||||||
|
##
|
||||||
|
## This test drives the real navigation flow (change_scene_to_file) against
|
||||||
|
## a live backend:
|
||||||
|
## 1. Register a unique user so ApiClient holds a valid token.
|
||||||
|
## 2. Load main_menu.tscn and assert it restores the authenticated state.
|
||||||
|
## 3. Click Play -> world_list.tscn.
|
||||||
|
## 4. Click Back -> main_menu.tscn.
|
||||||
|
## 5. Assert the menu is STILL authenticated (Play enabled, welcome label
|
||||||
|
## visible, login/register buttons hidden).
|
||||||
|
##
|
||||||
|
## Run with:
|
||||||
|
## godot --headless --path . --script res://scripts/smoke_test_main_menu_back.gd
|
||||||
|
##
|
||||||
|
## Requires the backend to be running (default base URL
|
||||||
|
## `application/agp/api_base_url` -> http://127.0.0.1:8000).
|
||||||
|
|
||||||
|
const USERNAME := "backnav_%d"
|
||||||
|
const EMAIL := "backnav_%d@test.example.com"
|
||||||
|
const PASSWORD := "BackNav123!"
|
||||||
|
const DOB := "2000-01-01"
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var api := root.get_node("ApiClient")
|
||||||
|
if api == null:
|
||||||
|
printerr("FAIL: ApiClient autoload not found")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Establish a real, valid session exactly the way a user would.
|
||||||
|
randomize()
|
||||||
|
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
|
||||||
|
var username := USERNAME % unique_id
|
||||||
|
var email := EMAIL % unique_id
|
||||||
|
print("\n=== Setup: register ", username, " ===")
|
||||||
|
var register_result: Dictionary = await api.register(username, email, PASSWORD, DOB)
|
||||||
|
if not _check("register succeeds", register_result.ok):
|
||||||
|
print(" detail: ", register_result.detail)
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
_check("ApiClient stores token after register", api.has_token())
|
||||||
|
|
||||||
|
# 2. First visit to the main menu: should already show the authenticated
|
||||||
|
# state (no login form, Play enabled).
|
||||||
|
change_scene_to_file("res://scenes/main_menu.tscn")
|
||||||
|
# change_scene_to_file defers the actual swap -- a couple frames is
|
||||||
|
# plenty for the scene object itself to exist (unlike the real
|
||||||
|
# get_me() network round-trip afterward, which is what auth_state_ready
|
||||||
|
# actually waits for below).
|
||||||
|
for i in 5:
|
||||||
|
await process_frame
|
||||||
|
if current_scene is Control:
|
||||||
|
break
|
||||||
|
|
||||||
|
var menu: Control = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
await menu.auth_state_ready
|
||||||
|
_check("first main menu visit restores authenticated state", _is_authenticated(menu))
|
||||||
|
|
||||||
|
# 3. Play -> world_list.tscn (real button press).
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
play_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(menu)
|
||||||
|
|
||||||
|
var world_list_scene := current_scene
|
||||||
|
if world_list_scene == null or world_list_scene == menu:
|
||||||
|
printerr("FAIL: world_list.tscn did not become current_scene after Play")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Back -> main_menu.tscn (real button press).
|
||||||
|
var back_button: Button = world_list_scene.get_node("VBoxContainer/HeaderHBox/BackButton")
|
||||||
|
back_button.pressed.emit()
|
||||||
|
await _wait_for_scene_change(world_list_scene)
|
||||||
|
|
||||||
|
menu = current_scene
|
||||||
|
if menu == null or not menu is Control:
|
||||||
|
printerr("FAIL: main_menu.tscn did not become current_scene after Back")
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
|
||||||
|
# The new main_menu instance's own _ready() is *also* awaiting a real
|
||||||
|
# get_me() call -- _wait_for_scene_change only confirms the scene
|
||||||
|
# object exists, not that its authenticated-state fields have been
|
||||||
|
# set yet. Wait for the real signal, not a guess.
|
||||||
|
await menu.auth_state_ready
|
||||||
|
|
||||||
|
# 5. THE regression assertion: still logged in after returning.
|
||||||
|
_check("main menu stays authenticated after Play -> Back", _is_authenticated(menu))
|
||||||
|
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_authenticated(menu: Control) -> bool:
|
||||||
|
var play_button: Button = menu.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
var welcome_label: Label = menu.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var auth_buttons_row: HBoxContainer = menu.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var auth_panel: PanelContainer = menu.get_node("CenterContainer/VBoxContainer/AuthPanel")
|
||||||
|
|
||||||
|
_check("Play enabled", not play_button.disabled)
|
||||||
|
_check("Welcome label visible", welcome_label.visible)
|
||||||
|
_check("Auth buttons row hidden", not auth_buttons_row.visible)
|
||||||
|
_check("Auth panel hidden", not auth_panel.visible)
|
||||||
|
|
||||||
|
return (not play_button.disabled) \
|
||||||
|
and welcome_label.visible \
|
||||||
|
and not auth_buttons_row.visible \
|
||||||
|
and not auth_panel.visible
|
||||||
|
|
||||||
|
|
||||||
|
func _wait_for_scene_change(previous_scene: Node) -> void:
|
||||||
|
for i in 20:
|
||||||
|
await process_frame
|
||||||
|
if current_scene != previous_scene and current_scene != null:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> bool:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Full click-through simulation of the real user flow: press Register,
|
||||||
|
## fill the form exactly as a human would, submit, confirm Play actually
|
||||||
|
## becomes clickable. This is the check that would have caught the
|
||||||
|
## original bug (no way to even open the form) and would catch a repeat
|
||||||
|
## of it.
|
||||||
|
|
||||||
|
var passed := 0
|
||||||
|
var failed := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var packed: PackedScene = load("res://scenes/main_menu.tscn")
|
||||||
|
var instance: Control = packed.instantiate()
|
||||||
|
root.add_child(instance)
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
var register_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow/RegisterButton")
|
||||||
|
var auth_buttons_row: HBoxContainer = instance.get_node("CenterContainer/VBoxContainer/AuthButtonsRow")
|
||||||
|
var username_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit")
|
||||||
|
var password_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit")
|
||||||
|
var email_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit")
|
||||||
|
var dob_edit: LineEdit = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DOBEdit")
|
||||||
|
var auth_button: Button = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton")
|
||||||
|
var welcome_label: Label = instance.get_node("CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel")
|
||||||
|
var play_button: Button = instance.get_node("CenterContainer/VBoxContainer/PlayButton")
|
||||||
|
|
||||||
|
_check("Play starts disabled", play_button.disabled)
|
||||||
|
|
||||||
|
# Click Register -- a real signal emission is fine here since
|
||||||
|
# _on_register_button_pressed() is synchronous (no await), same as a
|
||||||
|
# real click's GUI event dispatch would drive it.
|
||||||
|
register_button.pressed.emit()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
_check("auth panel opened in register mode", instance.get_node("CenterContainer/VBoxContainer/AuthPanel").visible)
|
||||||
|
|
||||||
|
# Fill the form exactly as a user would type into it.
|
||||||
|
var unique_id := int(Time.get_unix_time_from_system() * 1000)
|
||||||
|
username_edit.text = "mainmenu_e2e_%d" % unique_id
|
||||||
|
password_edit.text = "TestPass123!"
|
||||||
|
email_edit.text = "mainmenu_e2e_%d@test.example.com" % unique_id
|
||||||
|
dob_edit.text = "2000-01-01"
|
||||||
|
|
||||||
|
# Call the submit handler directly (awaited) rather than via
|
||||||
|
# auth_button.pressed.emit() -- a synthetic .emit() in --script/
|
||||||
|
# SceneTree headless mode doesn't drive an await-using handler's
|
||||||
|
# coroutine forward the same way real GUI input event dispatch does
|
||||||
|
# (confirmed via a direct-call vs emit() comparison: the direct call
|
||||||
|
# always resolves, the synthetic emit sometimes doesn't advance past
|
||||||
|
# its own await point even after 50 real process_frame waits). This
|
||||||
|
# is a headless-mode test-harness limitation, not a bug in the actual
|
||||||
|
# click-driven flow.
|
||||||
|
await instance._on_auth_button_pressed()
|
||||||
|
|
||||||
|
_check("Play became enabled after real register submission", not play_button.disabled)
|
||||||
|
_check("Welcome label visible", welcome_label.visible)
|
||||||
|
_check("Auth buttons row hid itself after success", not auth_buttons_row.visible)
|
||||||
|
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
quit(0 if failed == 0 else 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(description: String, condition: bool) -> void:
|
||||||
|
if condition:
|
||||||
|
print(" PASS: ", description)
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
printerr(" FAIL: ", description)
|
||||||
|
failed += 1
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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. 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.
|
||||||
|
## 4. Wait for _refresh_worlds()'s awaited request to complete.
|
||||||
|
## 5. Confirm it actually reached the populated (not empty) state, and
|
||||||
|
## that the rendered card has a real width -- not just non-zero
|
||||||
|
## child_count. 2026-08-20: a real bug shipped where every card
|
||||||
|
## technically existed in the tree (child_count > 0, no error, no
|
||||||
|
## crash) but rendered at 1.0px wide because size_flags_horizontal/
|
||||||
|
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
|
||||||
|
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
|
||||||
|
## child-count check alone would never catch that, only a real size
|
||||||
|
## assertion does.
|
||||||
|
##
|
||||||
|
## Exits 0 on full pass, 1 on any failure.
|
||||||
|
|
||||||
|
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
|
||||||
|
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_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
|
||||||
|
|
||||||
|
# --- Seed a real world so the populated-card path is actually exercised ---
|
||||||
|
print("\n=== Setup: seed a real world ===")
|
||||||
|
var world_body := JSON.stringify({
|
||||||
|
"name": "Smoke Test World %d" % unique_id,
|
||||||
|
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
|
||||||
|
"visibility": "public",
|
||||||
|
})
|
||||||
|
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
|
||||||
|
if not _check("setup: world created", create_result.ok):
|
||||||
|
print(" detail: ", create_result.detail)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Test 1: Instantiate the real scene ---
|
||||||
|
print("\n=== Test 1: Instantiate world_list.tscn ===")
|
||||||
|
var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
|
||||||
|
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 empty_label.visible:
|
||||||
|
# We just seeded a world -- ending up empty means GET /api/worlds
|
||||||
|
# isn't actually seeing what POST /api/worlds/ just created.
|
||||||
|
_check("ended in populated state, not empty (a world was just seeded)", false)
|
||||||
|
failed += 1
|
||||||
|
elif world_list_node.visible:
|
||||||
|
var card_count := world_list_node.get_child_count()
|
||||||
|
print(" Note: rendered %d world card(s)" % card_count)
|
||||||
|
|
||||||
|
if _check("at least one card rendered", card_count > 0):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# The actual bug this test exists to catch: a card can be present
|
||||||
|
# in the tree, not crash, and still be visually nothing because
|
||||||
|
# it rendered at ~1px wide. Check every card's real computed size,
|
||||||
|
# not just that it exists.
|
||||||
|
for i in range(card_count):
|
||||||
|
var card: Control = world_list_node.get_child(i)
|
||||||
|
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
|
||||||
|
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||||
|
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
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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:
|
||||||
|
# PanelContainer for the background, not a ColorRect moved to child index
|
||||||
|
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
|
||||||
|
# vertical sequence, it has no concept of "float this one child behind
|
||||||
|
# the others as an overlay". That version rendered as an empty dark bar
|
||||||
|
# sitting above the text, not a background behind it.
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
panel.custom_minimum_size = Vector2(0, 80)
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
|
||||||
|
style.content_margin_left = 12
|
||||||
|
style.content_margin_right = 12
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
|
var card := VBoxContainer.new()
|
||||||
|
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
card.add_theme_constant_override("separation", 4)
|
||||||
|
panel.add_child(card)
|
||||||
|
|
||||||
|
var name_label := Label.new()
|
||||||
|
name_label.text = world_dict.get("name", "Unnamed World")
|
||||||
|
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)
|
||||||
|
|
||||||
|
return panel
|
||||||
Reference in New Issue
Block a user