fix(creation-screen): the guards did not guard

Review found three tests in test_character_creation_screen.gd could not fail
against the bug they named — including both tests claiming to enforce §7
(Luck is invisible) and the only test covering origin->calling gating. The
five-ability-cards test asserted the script's own range(5) loop bound, not
the scene. The Luck sweep filtered on `Label` and missed both RichTextLabels
(the only nodes rendering authored prose) plus every Button and the
LineEdit. The reroll test's pool assertion already held true before the
reroll ran. The origin-gating test ran only against the deserter origin,
which allows all 7 callings, so shown==allowed was a trivial 7==7.

Rewrote all four to assert against the scene, the rules tables, or a
restrictive injected origin instead of values the code already guaranteed.
Added a node-count guard tying authored cards/chips to Races/Callings/
Skills/Attributes table sizes. Deleted the test-only
_complete_a_valid_draft_for_test() from the shipped Control and replaced it
with a test-file helper that drives the real click handlers, closing the
gap where no test touched _on_race_pressed/_on_calling_pressed/
_on_pool_pressed/_on_bonus_pressed at all. Guarded _ready() against a
degenerate origin (empty/missing allowed_callings) indexing an empty array.

Each fixed guard was deliberately re-broken and confirmed red before being
reverted (see .superpowers/sdd/task-6-report-fix.md, gitignored). 291 tests
green, up from 289.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:19:10 -05:00
parent 2227d703f6
commit 6589d7c4ed
2 changed files with 123 additions and 26 deletions

View File

@@ -69,7 +69,12 @@ func _ready() -> void:
draft = CreationDraft.fresh()
draft.set_race(Races.IDS[0])
draft.set_calling(_allowed_callings()[0])
var allowed := _allowed_callings()
if not allowed.is_empty():
draft.set_calling(allowed[0])
# A degenerate origin (zero allowed callings, or no build_constraints at all)
# leaves calling_id unset rather than crashing — §13, never an error thrown
# at the player. The CTA simply stays disabled; there is no calling to invent.
_bind()
@@ -285,21 +290,3 @@ func _bind_cta() -> void:
var errors := draft.errors(origin, world)
_enter.disabled = not errors.is_empty()
_error.text = str(errors[0]) if not errors.is_empty() else CTA_HINT
# ---------------------------------------------------------------- test seam
func _complete_a_valid_draft_for_test() -> void:
## Drives the draft to a legal state the way a player would. Test-only, and it
## touches nothing the player's own clicks do not.
draft.name = "Aldric"
draft.set_race("human")
draft.set_calling("sellsword")
for skill in draft.skill_pool():
if draft.can_pick(skill):
draft.toggle_skill(skill)
for skill in Skills.IDS:
if draft.can_take_bonus(skill):
draft.set_bonus_skill(skill)
break
_bind()

View File

@@ -20,6 +20,39 @@ func _screen() -> CharacterCreation:
return node
func _screen_with_origin(origin: Dictionary) -> CharacterCreation:
# Same seam as _screen(), but with an origin the CALLER controls — used to
# prove the calling-gate actually gates, which the real "deserter" origin
# (all 7 callings allowed) cannot do: 7 shown == 7 allowed is trivially true
# whether or not the hide branch in _bind_callings exists at all.
var node = load(SCENE).instantiate()
node.world = _world()
node.origin = origin
add_child_autofree(node)
return node
func _complete_a_valid_draft(s: CharacterCreation) -> void:
## Drives the draft to a legal state through the SAME public handlers a
## player's clicks call — _on_race_pressed / _on_calling_pressed /
## _on_pool_pressed / _on_bonus_pressed / _on_name_changed. Lives here, not in
## the shipped Control: character_creation.gd carries no test-only method.
s._name_edit.text = "Aldric"
s._on_name_changed("Aldric")
s._on_race_pressed(Races.IDS.find("human"))
s._on_calling_pressed(s._allowed_callings().find("sellsword"))
var pool: Array = s.draft.skill_pool()
for i in range(pool.size()):
s._on_pool_pressed(i)
for i in range(Skills.IDS.size()):
if s.draft.can_take_bonus(Skills.IDS[i]):
s._on_bonus_pressed(i)
break
func test_screen_applies_theme_and_fits_viewport():
var packed = load(SCENE)
assert_true(packed is PackedScene)
@@ -38,26 +71,97 @@ func test_a_draft_exists_with_a_seed_on_load():
func test_five_ability_cards_never_six():
# §7 — LCK has no card, no row, no tooltip. The player must never be able to
# CALCULATE that he is cursed.
#
# _ability_cards is populated by _collect_authored_nodes() looping
# `range(5)` — that loop is 5 by construction and would stay 5 even if the
# .tscn grew a 6th card. Assert against the SCENE's actual children instead.
var s := _screen()
assert_eq(s._ability_cards.size(), 5)
assert_eq(s._ability_row.get_child_count(), 5,
"five authored ability cards — the scene, not the script's range()")
assert_null(s._ability_row.get_node_or_null("Ab5"),
"there is no sixth ability card, and there never is (§7)")
for card in s._ability_cards:
assert_false(card.name.to_lower().contains("lck"))
func test_no_node_on_the_screen_says_luck():
# Sweep every Control, not just Label — RichTextLabel (_origin_text,
# _detail_blurb, the only two nodes rendering authored ContentDB prose)
# extends Control, not Label, and a `find_children(..., "Label", ...)` filter
# never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`)
# are swept too.
var s := _screen()
for node in s.find_children("*", "Label", true, false):
assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name)
assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name)
var checked_a_rich_text_label := false
for node in s.find_children("*", "Control", true, false):
if node is RichTextLabel:
checked_a_rich_text_label = true
for prop in ["text", "placeholder_text", "tooltip_text"]:
var value: Variant = node.get(prop)
if typeof(value) != TYPE_STRING:
continue
var lowered: String = value.to_lower()
assert_false(lowered.contains("luck"), "%s.%s leaks Luck (§7)" % [node.name, prop])
assert_false(lowered.contains("lck"), "%s.%s leaks Luck (§7)" % [node.name, prop])
assert_true(checked_a_rich_text_label,
"the sweep must actually reach the RichTextLabels or it is checking nothing")
func test_only_the_origin_s_allowed_callings_are_shown():
# The deserter origin allows all 7 callings, so against it "shown == allowed"
# is a trivial 7 == 7 that would stay green even with the hide branch in
# _bind_callings deleted outright. Use a RESTRICTIVE origin, deliberately out
# of Callings.IDS order, so the gate is doing real work.
var allowed := ["bonesetter", "sellsword", "cutpurse"]
var s := _screen_with_origin({"build_constraints": {"allowed_callings": allowed}})
var expected_order: Array = []
for id in Callings.IDS: # Callings.IDS fixes the ORDER
if id in allowed: # the origin fixes the SET
expected_order.append(id)
assert_eq(expected_order.size(), 3)
var shown := 0
for i in range(s._calling_cards.size()):
var card: Button = s._calling_cards[i]
if i < expected_order.size():
assert_true(card.visible, "calling %s must be shown" % expected_order[i])
var expected_name: String = str(
s.world.calling(expected_order[i]).get("name", expected_order[i].capitalize()))
assert_eq(card.get_node("Box/Name").text, expected_name,
"visible cards must appear in Callings.IDS order, not the origin's list order")
shown += 1
else:
assert_false(card.visible,
"a calling the origin disallows stays hidden, not deleted")
assert_eq(shown, 3)
func test_the_deserter_origin_shows_all_seven_callings():
var s := _screen()
var shown := 0
for card in s._calling_cards:
if card.visible:
shown += 1
assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size())
assert_eq(shown, 7)
func test_authored_node_counts_match_the_rules_tables():
# _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race,
# an 8th calling, a 10th skill) must go red HERE, before a binder silently
# under-renders or indexes a table out of range at runtime.
var s := _screen()
assert_eq(s._race_cards.size(), Races.IDS.size())
assert_eq(s._calling_cards.size(), Callings.IDS.size())
assert_eq(s._bonus_chips.size(), Skills.IDS.size())
assert_eq(s._ability_cards.size(), Attributes.IDS.size())
# The pool row's 6 is the WIDEST calling's skill pool, not a table length —
# assert it covers every calling, so a 7-wide pool added later goes red
# instead of silently dropping a chip.
for id in Callings.IDS:
assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(),
"the pool row must fit %s's skill pool" % id)
func test_cta_is_disabled_until_the_draft_is_legal():
@@ -66,7 +170,7 @@ func test_cta_is_disabled_until_the_draft_is_legal():
assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly")
assert_ne(s._error.text.strip_edges(), "")
s._complete_a_valid_draft_for_test()
_complete_a_valid_draft(s)
assert_false(s._enter.disabled)
assert_eq(s._error.text, CharacterCreation.CTA_HINT,
"a legal draft gets the mock's line back, not a blank")
@@ -74,7 +178,7 @@ func test_cta_is_disabled_until_the_draft_is_legal():
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
var s := _screen()
s._complete_a_valid_draft_for_test()
_complete_a_valid_draft(s)
watch_signals(s)
s._on_enter_pressed()
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
@@ -89,7 +193,7 @@ func test_the_screen_never_constructs_the_character():
func test_the_displayed_scores_are_the_draft_s_scores():
var s := _screen()
s._complete_a_valid_draft_for_test()
_complete_a_valid_draft(s)
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var value_label: Label = s._ability_cards[i].get_node("Box/Value")
@@ -99,6 +203,12 @@ func test_the_displayed_scores_are_the_draft_s_scores():
func test_reroll_changes_what_is_on_screen():
var s := _screen()
# Spend a point BEFORE the re-roll — a fresh draft's pool already reads
# "points left: 3" with nothing spent, so asserting that value after reroll
# alone proves nothing about whether reroll() actually clears the spend.
s._on_plus_pressed(0)
assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll")
var before := s.draft.character_seed
s._on_reroll_pressed()
assert_ne(s.draft.character_seed, before)