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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user