Fix: world cards render at ~1px wide once real data exists

Reported directly by the user after seeding a test world through the
browse-worlds screen: "screen is blank now". Two compounding layout
bugs, both real:

1. world_list.tscn's root VBoxContainer had no anchors at all, only
   size_flags -- but its parent (the scene's root node) is a plain
   Control, not a Container, so size_flags on a Control's direct child
   are meaningless there. Without anchors it never actually filled the
   screen, so it (and everything under it) collapsed toward zero size.

2. size_flags_horizontal/vertical used the raw integer 4 everywhere
   expand-fill was clearly intended (ScrollContainer, its WorldList
   child, HeaderHBox, TitleLabel) -- but 4 is Control.SIZE_SHRINK_CENTER
   in Godot 4, not SIZE_EXPAND_FILL (which is 3). ScrollContainer with
   SHRINK_CENTER takes only its own minimum size instead of expanding,
   cascading a ~1px width down through every card and every label.
   Confirmed the actual enum values by asking Godot directly rather
   than assuming.

Both are exactly the kind of bug a headless smoke test checking only
"did cards get added to the tree" can't catch -- child_count was
correctly non-zero, nothing crashed, the content was just rendered
invisibly thin. Verified the fix is real by deliberately reintroducing
bug #2 and confirming the test now correctly fails (card width 25px,
below the 200px sanity threshold) before restoring it.

Also replaced the card background: it was a ColorRect manually moved to
child index 0 of a VBoxContainer, which doesn't work as an overlay --
a VBoxContainer lays out ALL its children in sequence, so the "moved to
front" ColorRect rendered as an empty dark bar ABOVE the text, not
behind it. Now a proper PanelContainer with a StyleBoxFlat background,
the idiomatic way to get a colored panel behind auto-laid-out content
in Godot.

smoke_test_world_list.gd strengthened to actually catch this class of
bug going forward: seeds a real world via POST /api/worlds/ first (the
checkpoint runner's fresh DB meant the populated-card path never ran in
CI at all before this, only empty-list did), and asserts each card's
real computed width, not just that cards exist.

Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests, both platform exports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
claude-checkpoint-tooling
2026-08-20 13:35:46 +00:00
parent 3679e4dbc4
commit 8fc9d378e4
4 changed files with 91 additions and 32 deletions
+8 -4
View File
@@ -7,9 +7,13 @@ 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-20 (commit `927f9bd`) — full PC1 Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
checkpoint run, fresh clone, migrations + pytest + all 4 Godot smoke tests + migrations + pytest + all 6 Godot smoke tests + both platform exports, all
both platform exports, all clean. 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)
@@ -58,7 +62,7 @@ these yet):
| `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. | | `smoke_test_api.gd` | Exercises `ApiClient.health_check()` against a live backend. Verifies the real HTTP request path (`HTTPRequest` node lifecycle, JSON parse, result-dictionary contract). Exits 1 on failure. |
| `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). | | `smoke_test_auth.gd` | Tests the full auth flow via `ApiClient` autoload: register unique user (201), duplicate username (409), under-18 registration (400), correct login (200), wrong password (401), nonexistent user (401). |
| `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. | | `smoke_test_e2e.gd` | End-to-end flow: health check → register → login → worlds list. Verifies the full request chain with a fresh user, including token storage and `get_worlds()` with auth header. |
| `smoke_test_world_list.gd` | Instantiates `world_list.tscn` as a real scene, adds to tree (catches `@onready` node-path bugs), waits for `_refresh_worlds()` to complete, confirms the screen reaches a real end state (world cards, empty-list message, or error label — not stuck loading). | | `smoke_test_world_list.gd` | Registers a user, **seeds a real world via `POST /api/worlds/`** (without this, `run_checkpoint_tests.py`'s always-fresh DB means the populated-card render path never actually runs in CI, only the empty-list path would), instantiates `world_list.tscn` as a real scene, waits for `_refresh_worlds()` to complete, confirms it reached the populated (not empty) state, and asserts **each rendered card's real computed width is >= 200px**. That last check is the one that matters: 2026-08-20, a real bug shipped where every card existed in the tree with a non-zero child count and no crash, but rendered at 1.0px wide (`ScrollContainer`'s `size_flags_horizontal`/`vertical` used the raw integer `4` = `Control.SIZE_SHRINK_CENTER` instead of `3` = `SIZE_EXPAND_FILL`) — invisible on screen, and a child-count-only check would never have caught it. Confirmed by deliberately reintroducing the bug: card width check correctly fails at 25px. |
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. | | `smoke_test_main_menu_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. | | `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. |
+11 -9
View File
@@ -11,14 +11,17 @@ grow_vertical = 2
script = ExtResource("1_wl") script = ExtResource("1_wl")
[node name="VBoxContainer" type="VBoxContainer" parent="."] [node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 3 layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 8 theme_override_constants/separation = 8
size_flags_horizontal = 4
size_flags_vertical = 4
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"] [node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
theme_override_constants/separation = 16 theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"] [node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
layout_mode = 3 layout_mode = 3
text = "Browse Worlds" text = "Browse Worlds"
horizontal_alignment = 1 horizontal_alignment = 1
size_flags_horizontal = 4 size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28 theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] [node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] [node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
size_flags_vertical = 4 size_flags_vertical = 3
horizontal_mode = 2 horizontal_mode = 2
vertical_mode = 2 vertical_mode = 2
[node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"] [node name="WorldList" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
layout_mode = 3 layout_mode = 3
size_flags_horizontal = 4 size_flags_horizontal = 3
size_flags_vertical = 4
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"] [node name="LoadingLabel" type="Label" parent="VBoxContainer"]
+53 -7
View File
@@ -16,15 +16,27 @@ extends SceneTree
## ##
## Tests: ## Tests:
## 1. Register + log in a fresh user (so get_worlds() has a valid token). ## 1. Register + log in a fresh user (so get_worlds() has a valid token).
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution ## 2. POST a real world via the API so the populated-card path actually
## gets exercised -- run_checkpoint_tests.py always starts from a
## genuinely empty DB, so without this the card-rendering code below
## would never run in CI at all, only the empty-list path would.
## 3. Instantiate world_list.tscn, add to tree -- @onready resolution
## must not error. ## must not error.
## 3. Wait for _refresh_worlds()'s awaited request to complete. ## 4. Wait for _refresh_worlds()'s awaited request to complete.
## 4. Confirm the screen reached a real end state (world cards, or the ## 5. Confirm it actually reached the populated (not empty) state, and
## empty-list message -- either is fine, a crash/stuck-loading isn't). ## that the rendered card has a real width -- not just non-zero
## child_count. 2026-08-20: a real bug shipped where every card
## technically existed in the tree (child_count > 0, no error, no
## crash) but rendered at 1.0px wide because size_flags_horizontal/
## vertical used the raw integer 4 (Control.SIZE_SHRINK_CENTER)
## instead of 3 (Control.SIZE_EXPAND_FILL) on ScrollContainer -- a
## child-count check alone would never catch that, only a real size
## assertion does.
## ##
## Exits 0 on full pass, 1 on any failure. ## Exits 0 on full pass, 1 on any failure.
const WORLD_LIST_SCENE := "res://scenes/world_list.tscn" const WORLD_LIST_SCENE := "res://scenes/world_list.tscn"
const MIN_SANE_CARD_WIDTH := 200.0 # a real screen is at minimum a few hundred px wide
func _initialize() -> void: func _initialize() -> void:
@@ -52,6 +64,19 @@ func _run() -> void:
quit(1) quit(1)
return return
# --- Seed a real world so the populated-card path is actually exercised ---
print("\n=== Setup: seed a real world ===")
var world_body := JSON.stringify({
"name": "Smoke Test World %d" % unique_id,
"description": "Seeded by smoke_test_world_list.gd to exercise the populated-card render path.",
"visibility": "public",
})
var create_result: Dictionary = await api.request(HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true)
if not _check("setup: world created", create_result.ok):
print(" detail: ", create_result.detail)
quit(1)
return
# --- Test 1: Instantiate the real scene --- # --- Test 1: Instantiate the real scene ---
print("\n=== Test 1: Instantiate world_list.tscn ===") print("\n=== Test 1: Instantiate world_list.tscn ===")
var packed_scene: PackedScene = load(WORLD_LIST_SCENE) var packed_scene: PackedScene = load(WORLD_LIST_SCENE)
@@ -99,10 +124,31 @@ func _run() -> void:
if error_label.visible: if error_label.visible:
printerr(" Note: ended in error state: ", error_label.text) printerr(" Note: ended in error state: ", error_label.text)
elif world_list_node.visible:
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
elif empty_label.visible: elif empty_label.visible:
print(" Note: ended in empty-list state") # We just seeded a world -- ending up empty means GET /api/worlds
# isn't actually seeing what POST /api/worlds/ just created.
_check("ended in populated state, not empty (a world was just seeded)", false)
failed += 1
elif world_list_node.visible:
var card_count := world_list_node.get_child_count()
print(" Note: rendered %d world card(s)" % card_count)
if _check("at least one card rendered", card_count > 0):
passed += 1
else:
failed += 1
# The actual bug this test exists to catch: a card can be present
# in the tree, not crash, and still be visually nothing because
# it rendered at ~1px wide. Check every card's real computed size,
# not just that it exists.
for i in range(card_count):
var card: Control = world_list_node.get_child(i)
var wide_enough := card.size.x >= MIN_SANE_CARD_WIDTH
if _check("card %d has a real width (%.0fpx >= %.0fpx)" % [i, card.size.x, MIN_SANE_CARD_WIDTH], wide_enough):
passed += 1
else:
failed += 1
# --- Summary --- # --- Summary ---
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
+19 -12
View File
@@ -88,10 +88,26 @@ func _refresh_worlds() -> void:
## Create a visual card for a single world entry. ## Create a visual card for a single world entry.
func _create_world_card(world_dict: Dictionary) -> Control: func _create_world_card(world_dict: Dictionary) -> Control:
# PanelContainer for the background, not a ColorRect moved to child index
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
# vertical sequence, it has no concept of "float this one child behind
# the others as an overlay". That version rendered as an empty dark bar
# sitting above the text, not a background behind it.
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 80)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
panel.add_theme_stylebox_override("panel", style)
var card := VBoxContainer.new() var card := VBoxContainer.new()
card.size_flags_horizontal = Control.SIZE_FILL card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.custom_minimum_size = Vector2(0, 80)
card.add_theme_constant_override("separation", 4) card.add_theme_constant_override("separation", 4)
panel.add_child(card)
var name_label := Label.new() var name_label := Label.new()
name_label.text = world_dict.get("name", "Unnamed World") name_label.text = world_dict.get("name", "Unnamed World")
@@ -123,13 +139,4 @@ func _create_world_card(world_dict: Dictionary) -> Control:
card.add_child(desc_label) card.add_child(desc_label)
card.add_child(meta_label) card.add_child(meta_label)
# Subtle background for the card return panel
var bg := ColorRect.new()
bg.color = Color(0.15, 0.15, 0.15, 1.0)
bg.size_flags_horizontal = Control.SIZE_FILL
bg.size_flags_vertical = Control.SIZE_FILL
bg.custom_minimum_size = Vector2(0, 80)
card.add_child(bg)
card.move_child(bg, 0)
return card