4ff74ae823
Implements world_persistence_impl.md §9 client/editor task items 2-3: - ChunkSaveService (scripts/editor/chunk_save_service.gd): single-chunk PUT with base_revision, batch PUT for multi-chunk edits, and a real 409 -> re-fetch -> retry loop. Full-chunk overwrite (§8.2 D5) means "re-apply the edit" is just retrying with the locally-edited payload at the freshly-learned revision, not delta replay. - InGameEditor gets a Save button (+ Ctrl+S) that surfaces live_session as an explicit warning instead of ignoring it, per §6 D4. - "Edit World" entry point on the world list (owner-gated) that calls Session.begin_edit() instead of ApiClient.join_world() -- editing never joins, opens no ENet connection, spawns no dedicated server. - game.gd wires Session.editing to fetch the real (0,0,0) chunk over HTTP and open the editor against it, falling back to an empty chunk for an unwritten coordinate. Verified against a real backend (register/world/save/reload, a genuine 409 from two racing saves, atomic 2-chunk batch, live_session field) -- new scripts/smoke_test_editor_save.gd (19/19) plus an extended smoke_test_world_list.gd covering the non-joined entry point (11/11). Full existing Godot smoke suite (18 files) and backend pytest (132) still pass unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
216 lines
8.2 KiB
GDScript
216 lines
8.2 KiB
GDScript
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")
|
|
# Bare autoload identifiers (e.g. `Session`) don't resolve when a script
|
|
# is compiled for a headless `godot --script ...` run -- same note as
|
|
# HttpChunkSource._api_client() / ChunkSaveService._api_client().
|
|
var session := root.get_node("Session")
|
|
assert(session != null, "Session 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!", "1990-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
|
|
|
|
# --- Test 3: "Edit World" entry point (world_persistence_impl.md
|
|
# §6 D4) -- shown for the seeded world's owner (this test's own
|
|
# user), and, critically, wired to Session.begin_edit() rather than
|
|
# ApiClient.join_world() -- pressing it must never call /join.
|
|
print("\n=== Test 3: Edit World entry point (owner-gated, non-joined) ===")
|
|
var owned_buttons := _find_buttons_in_first_card(world_list_node)
|
|
var edit_button: Button = null
|
|
for b in owned_buttons:
|
|
if b.text == "Edit World":
|
|
edit_button = b
|
|
if _check("owner's card shows an Edit World button", edit_button != null):
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
|
|
if edit_button != null:
|
|
session.clear()
|
|
edit_button.pressed.emit()
|
|
var editing_set: bool = session.editing and not session.active and session.world_id == int((create_result.data as Dictionary)["id"])
|
|
if _check("pressing Edit World sets Session.editing (non-joined) for the right world, not Session.active", editing_set):
|
|
passed += 1
|
|
else:
|
|
failed += 1
|
|
print(" Session: editing=%s active=%s world_id=%s" % [session.editing, session.active, session.world_id])
|
|
session.clear()
|
|
|
|
# --- Summary ---
|
|
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
|
if failed > 0:
|
|
quit(1)
|
|
else:
|
|
print("SMOKE RESULT: ALL PASS")
|
|
quit(0)
|
|
|
|
|
|
## Depth-first collect every Button under the first rendered world card.
|
|
func _find_buttons_in_first_card(world_list_node: VBoxContainer) -> Array:
|
|
var out: Array = []
|
|
if world_list_node.get_child_count() == 0:
|
|
return out
|
|
_collect_buttons(world_list_node.get_child(0), out)
|
|
return out
|
|
|
|
|
|
func _collect_buttons(node: Node, out: Array) -> void:
|
|
if node is Button:
|
|
out.append(node)
|
|
for c in node.get_children():
|
|
_collect_buttons(c, out)
|
|
|
|
|
|
func _check(description: String, condition: bool) -> bool:
|
|
if condition:
|
|
print(" PASS: ", description)
|
|
return true
|
|
else:
|
|
printerr(" FAIL: ", description)
|
|
return false
|