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`.
Last verified against `main`: 2026-08-20 (commit `927f9bd`) — full PC1
checkpoint run, fresh clone, migrations + pytest + all 4 Godot smoke tests +
both platform exports, all clean.
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)
@@ -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_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_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_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")
[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
size_flags_horizontal = 4
size_flags_vertical = 4
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -30,7 +33,7 @@ size_flags_horizontal = 0
layout_mode = 3
text = "Browse Worlds"
horizontal_alignment = 1
size_flags_horizontal = 4
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 28
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
@@ -40,15 +43,14 @@ size_flags_horizontal = 0
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 3
size_flags_horizontal = 4
size_flags_vertical = 4
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 = 4
size_flags_vertical = 4
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="LoadingLabel" type="Label" parent="VBoxContainer"]
+53 -7
View File
@@ -16,15 +16,27 @@ extends SceneTree
##
## Tests:
## 1. Register + log in a fresh user (so get_worlds() has a valid token).
## 2. Instantiate world_list.tscn, add to tree -- @onready resolution
## 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.
## 3. Wait for _refresh_worlds()'s awaited request to complete.
## 4. Confirm the screen reached a real end state (world cards, or the
## empty-list message -- either is fine, a crash/stuck-loading isn't).
## 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:
@@ -52,6 +64,19 @@ func _run() -> void:
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)
@@ -99,10 +124,31 @@ func _run() -> void:
if error_label.visible:
printerr(" Note: ended in error state: ", error_label.text)
elif world_list_node.visible:
print(" Note: rendered %d world card(s)" % world_list_node.get_child_count())
elif empty_label.visible:
print(" Note: ended in empty-list state")
# 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])
+19 -12
View File
@@ -88,10 +88,26 @@ func _refresh_worlds() -> void:
## 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_FILL
card.custom_minimum_size = Vector2(0, 80)
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")
@@ -123,13 +139,4 @@ func _create_world_card(world_dict: Dictionary) -> Control:
card.add_child(desc_label)
card.add_child(meta_label)
# Subtle background for the card
var bg := ColorRect.new()
bg.color = Color(0.15, 0.15, 0.15, 1.0)
bg.size_flags_horizontal = Control.SIZE_FILL
bg.size_flags_vertical = Control.SIZE_FILL
bg.custom_minimum_size = Vector2(0, 80)
card.add_child(bg)
card.move_child(bg, 0)
return card
return panel