test(creation-screen): close 3 vacuous guards — point-buy display, signal wiring, Luck sweep
Adversarial re-review of 6589d7c found three assertions in the same species as
the seven it fixed: green against the bug they name.
- test_the_displayed_scores_are_the_draft_s_scores spent zero points, so
final() == rolled() and a Box/Value bound to `rolled[stat]` instead of
`final[stat]` would pass unnoticed. Now presses the Plus node to force a
divergence, and checks the Box/Rolled sub-label too (previously unchecked).
- No test ever emitted a node's `pressed` signal — every interaction went
through the _on_*_pressed handler directly, so _wire() itself, and every
card-to-index binding, had zero coverage. Minus had no coverage of any
kind. Helper and interaction tests now press the authored nodes; added
dedicated press tests for race/calling/pool/bonus/minus, plus the
Plus/Minus disabled-boundary bindings.
- The Luck sweep only ever checked the default binding (human + sellsword),
covering 1 of the 28 race x calling combinations whose authored prose
(fragment/blurb) actually renders on screen. Now sweeps all 28.
Plus three cheap adds: a degenerate-origin (§13) guard, orphan-authored-card
counts for the race/calling/bonus rows, and a non-empty assertion on the
origin-card RichTextLabel.
Each fix verified by deliberately reintroducing its named bug, confirming the
guard test goes red, then reverting — see
.superpowers/sdd/task-6-report-fix2.md. 291 -> 298 tests, all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,23 +33,24 @@ func _screen_with_origin(origin: Dictionary) -> CharacterCreation:
|
||||
|
||||
|
||||
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.
|
||||
## Drives the draft to a legal state by pressing the same AUTHORED NODES a
|
||||
## player's clicks fire — not by calling the _on_*_pressed handlers directly.
|
||||
## Calling the handler proves the handler works; it proves nothing about
|
||||
## _wire() actually connecting the node's `pressed` signal to it. Pressing the
|
||||
## node is the only path that exercises both at once.
|
||||
s._name_edit.text = "Aldric"
|
||||
s._on_name_changed("Aldric")
|
||||
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text in a test.
|
||||
|
||||
s._on_race_pressed(Races.IDS.find("human"))
|
||||
s._on_calling_pressed(s._allowed_callings().find("sellsword"))
|
||||
s._race_cards[Races.IDS.find("human")].pressed.emit()
|
||||
s._calling_cards[s._allowed_callings().find("sellsword")].pressed.emit()
|
||||
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
for i in range(pool.size()):
|
||||
s._on_pool_pressed(i)
|
||||
s._pool_chips[i].pressed.emit()
|
||||
|
||||
for i in range(Skills.IDS.size()):
|
||||
if s.draft.can_take_bonus(Skills.IDS[i]):
|
||||
s._on_bonus_pressed(i)
|
||||
s._bonus_chips[i].pressed.emit()
|
||||
break
|
||||
|
||||
|
||||
@@ -91,20 +92,50 @@ func test_no_node_on_the_screen_says_luck():
|
||||
# extends Control, not Label, and a `find_children(..., "Label", ...)` filter
|
||||
# never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`)
|
||||
# are swept too.
|
||||
#
|
||||
# The two RichTextLabels render race.fragment / calling.fragment /
|
||||
# calling.blurb, which DIFFER per race and per calling. Checking only the
|
||||
# default binding (human + sellsword) means a "luck" in, say, the Reaver's
|
||||
# blurb or the Beastfolk's fragment would reach the screen and never be seen.
|
||||
# Rebind across every race x every allowed calling (the deserter origin used
|
||||
# by _screen() allows all 7, so this is a full 4 x 7 = 28-combination sweep).
|
||||
var s := _screen()
|
||||
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])
|
||||
var combos_checked := 0
|
||||
|
||||
for race_i in range(Races.IDS.size()):
|
||||
s._race_cards[race_i].pressed.emit()
|
||||
var allowed: Array = s._allowed_callings()
|
||||
for calling_i in range(allowed.size()):
|
||||
s._calling_cards[calling_i].pressed.emit()
|
||||
combos_checked += 1
|
||||
var combo := "%s + %s" % [Races.IDS[race_i], allowed[calling_i]]
|
||||
|
||||
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) at %s" % [node.name, prop, combo])
|
||||
assert_false(lowered.contains("lck"),
|
||||
"%s.%s leaks Luck (§7) at %s" % [node.name, prop, combo])
|
||||
|
||||
# `checked_a_rich_text_label` alone only proves the sweep REACHED a
|
||||
# RichTextLabel, not that it had anything in it — an empty label would
|
||||
# pass the Luck check for a reason that has nothing to do with §7.
|
||||
assert_false(s._origin_text.text.strip_edges().is_empty(),
|
||||
"the origin card must actually render prose at %s" % combo)
|
||||
assert_false(s._detail_blurb.text.strip_edges().is_empty(),
|
||||
"the calling detail must actually render prose at %s" % combo)
|
||||
|
||||
assert_true(checked_a_rich_text_label,
|
||||
"the sweep must actually reach the RichTextLabels or it is checking nothing")
|
||||
assert_eq(combos_checked, Races.IDS.size() * Callings.IDS.size(),
|
||||
"every race must be checked against every allowed calling — 4 x 7, not just the default binding")
|
||||
|
||||
|
||||
func test_only_the_origin_s_allowed_callings_are_shown():
|
||||
@@ -147,6 +178,24 @@ func test_the_deserter_origin_shows_all_seven_callings():
|
||||
assert_eq(shown, 7)
|
||||
|
||||
|
||||
func test_a_degenerate_origin_disables_entry_without_throwing():
|
||||
# §13 — a degrade, never a crash. An origin with no build_constraints at all
|
||||
# (or an empty allowed_callings list) must leave the draft with no calling to
|
||||
# invent, every calling card hidden, and the CTA disabled — quietly.
|
||||
#
|
||||
# NOT {} — CharacterCreation._ready() treats an EMPTY origin Dictionary as
|
||||
# "none supplied" and substitutes the deserter origin (`origin.is_empty()`),
|
||||
# which allows all 7 callings and is not degenerate at all. A non-empty
|
||||
# Dictionary that simply carries no build_constraints is the real degenerate
|
||||
# case §13's comment in the source describes.
|
||||
var s := _screen_with_origin({"id": "test-degenerate-origin"})
|
||||
assert_eq(s.draft.calling_id, "",
|
||||
"no allowed callings means no calling to default into")
|
||||
for card in s._calling_cards:
|
||||
assert_false(card.visible, "every calling card hides when none are allowed")
|
||||
assert_true(s._enter.disabled, "a calling-less draft can never be legal")
|
||||
|
||||
|
||||
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
|
||||
@@ -163,6 +212,19 @@ func test_authored_node_counts_match_the_rules_tables():
|
||||
assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(),
|
||||
"the pool row must fit %s's skill pool" % id)
|
||||
|
||||
# The above ties the SCRIPT's arrays to the tables. It says nothing about the
|
||||
# SCENE — an orphan authored card (a stray "Race4" nobody wired or hid) would
|
||||
# render, unbound and visible, with every assertion above still green. Tie the
|
||||
# rows' actual child counts to the same tables.
|
||||
assert_eq(s._race_row.get_child_count(), Races.IDS.size(),
|
||||
"an orphan authored race card would sit here, unbound and visible")
|
||||
assert_eq(s._calling_row.get_child_count(), Callings.IDS.size(),
|
||||
"an orphan authored calling card would sit here, unbound and visible")
|
||||
# BonusRow's first child is its own section Label ("HUMAN — ONE MORE, ANY
|
||||
# SKILL") — not a chip — so its total is one MORE than Skills.IDS.size().
|
||||
assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1,
|
||||
"an orphan authored bonus chip would sit here, unbound and visible")
|
||||
|
||||
|
||||
func test_cta_is_disabled_until_the_draft_is_legal():
|
||||
var s := _screen()
|
||||
@@ -180,7 +242,7 @@ func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
|
||||
var s := _screen()
|
||||
_complete_a_valid_draft(s)
|
||||
watch_signals(s)
|
||||
s._on_enter_pressed()
|
||||
s._enter.pressed.emit()
|
||||
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
|
||||
|
||||
|
||||
@@ -194,22 +256,131 @@ func test_the_screen_never_constructs_the_character():
|
||||
func test_the_displayed_scores_are_the_draft_s_scores():
|
||||
var s := _screen()
|
||||
_complete_a_valid_draft(s)
|
||||
|
||||
# A fresh, unspent draft has final() == rolled() for EVERY stat, so comparing
|
||||
# the Value label against final() alone cannot tell a `final`/`rolled` swap in
|
||||
# the ability binder apart from the real thing. Spend a point — by pressing
|
||||
# the actual Plus BUTTON, not _on_plus_pressed, so a broken _wire() would also
|
||||
# be caught here — so final() and rolled() diverge and the assertion below has
|
||||
# to actually choose one.
|
||||
var spent_stat: String = Attributes.IDS[0]
|
||||
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(spent_stat, 0)), 1, "the spend actually landed")
|
||||
|
||||
var rolled: Dictionary = s.draft.rolled()
|
||||
var final: Dictionary = s.draft.final()
|
||||
assert_eq(int(final[spent_stat]), int(rolled[spent_stat]) + 1,
|
||||
"spending a point must move final away from rolled, or nothing below distinguishes them")
|
||||
|
||||
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")
|
||||
assert_eq(value_label.text, str(int(s.draft.final()[stat])),
|
||||
"%s shows what the draft holds" % stat)
|
||||
var card = s._ability_cards[i]
|
||||
var value_label: Label = card.get_node("Box/Value")
|
||||
var rolled_label: Label = card.get_node("Box/Rolled")
|
||||
assert_eq(value_label.text, str(int(final[stat])),
|
||||
"%s's Value must show final(), not rolled()" % stat)
|
||||
|
||||
var spent: int = int(s.draft.spend.get(stat, 0))
|
||||
var expected_rolled_text: String = "rolled %d%s" % [
|
||||
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
|
||||
assert_eq(rolled_label.text, expected_rolled_text,
|
||||
"%s's Rolled sub-label must show the roll and the spend, nothing else covers it" % stat)
|
||||
|
||||
|
||||
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)
|
||||
# Spend a point BEFORE the re-roll, by pressing the actual Plus button — 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._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll")
|
||||
|
||||
var before := s.draft.character_seed
|
||||
s._on_reroll_pressed()
|
||||
s._reroll.pressed.emit()
|
||||
assert_ne(s.draft.character_seed, before)
|
||||
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")
|
||||
|
||||
|
||||
func test_pressing_a_race_card_sets_the_draft_s_race():
|
||||
# Nothing in the suite ever emits a card's `pressed` signal — every existing
|
||||
# test called _on_race_pressed directly. A mis-wired card (bound to the wrong
|
||||
# index, or not connected at all — see _wire()) would leave every other test
|
||||
# green. Press the elf card itself.
|
||||
var s := _screen()
|
||||
var elf_i: int = Races.IDS.find("elf")
|
||||
s._race_cards[elf_i].pressed.emit()
|
||||
assert_eq(s.draft.race_id, "elf",
|
||||
"the CARD's pressed signal must reach the draft through _wire()")
|
||||
assert_true(s._race_cards[elf_i].get_node("Tag").visible, "the chosen card shows its tag")
|
||||
|
||||
|
||||
func test_pressing_a_calling_card_sets_the_draft_s_calling():
|
||||
var s := _screen()
|
||||
var idx: int = s._allowed_callings().find("cutpurse")
|
||||
s._calling_cards[idx].pressed.emit()
|
||||
assert_eq(s.draft.calling_id, "cutpurse",
|
||||
"the CARD's pressed signal must reach the draft through _wire()")
|
||||
|
||||
|
||||
func test_pressing_a_pool_chip_toggles_the_skill():
|
||||
var s := _screen()
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
var skill: String = pool[0]
|
||||
s._pool_chips[0].pressed.emit()
|
||||
assert_true(s.draft.is_picked(skill),
|
||||
"the CHIP's pressed signal must reach toggle_skill through _wire()")
|
||||
s._pool_chips[0].pressed.emit()
|
||||
assert_false(s.draft.is_picked(skill), "pressing it again toggles it back off")
|
||||
|
||||
|
||||
func test_pressing_a_bonus_chip_sets_the_bonus_skill():
|
||||
var s := _screen() # human is the default race, and wants a bonus skill
|
||||
var idx := -1
|
||||
for i in range(Skills.IDS.size()):
|
||||
if s.draft.can_take_bonus(Skills.IDS[i]):
|
||||
idx = i
|
||||
break
|
||||
assert_ne(idx, -1, "the default draft (human) must have a takeable bonus skill")
|
||||
s._bonus_chips[idx].pressed.emit()
|
||||
assert_eq(s.draft.bonus_skill, Skills.IDS[idx],
|
||||
"the CHIP's pressed signal must reach set_bonus_skill through _wire()")
|
||||
|
||||
|
||||
func test_pressing_the_minus_button_returns_a_spent_point():
|
||||
# Nothing else in the suite ever presses Minus — every prior test only ever
|
||||
# spent points, never gave one back.
|
||||
var s := _screen()
|
||||
var stat: String = Attributes.IDS[0]
|
||||
var card = s._ability_cards[0]
|
||||
card.get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(stat, 0)), 1)
|
||||
|
||||
card.get_node("Box/Buttons/Minus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(stat, 0)), 0,
|
||||
"the MINUS button's pressed signal must reach the draft through _wire()")
|
||||
assert_eq(s._points.text, "points left: 3")
|
||||
|
||||
|
||||
func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool():
|
||||
var s := _screen()
|
||||
var card = s._ability_cards[0]
|
||||
var plus: Button = card.get_node("Box/Buttons/Plus")
|
||||
var minus: Button = card.get_node("Box/Buttons/Minus")
|
||||
|
||||
assert_true(minus.disabled, "nothing has been spent yet — minus must be inert at the rolled floor")
|
||||
plus.pressed.emit()
|
||||
assert_false(minus.disabled, "a spent point makes minus live again")
|
||||
|
||||
# Drain the whole pool (SPEND_POOL == 3) into this one stat. points_left() is
|
||||
# POOL-wide, not per-card, so every Plus on the screen must go inert together.
|
||||
# A BOUNDED loop, deliberately — `while points_left() > 0` would hang forever
|
||||
# if _wire() were ever broken (the press would never reach the draft), turning
|
||||
# a guard meant to catch that exact bug into a stuck test run instead of a red one.
|
||||
var pool_size: int = s.draft.points_left()
|
||||
for i in range(pool_size + 1):
|
||||
plus.pressed.emit()
|
||||
assert_eq(s.draft.points_left(), 0)
|
||||
assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert")
|
||||
var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus")
|
||||
assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together")
|
||||
|
||||
Reference in New Issue
Block a user