diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 03e9235..55fe060 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -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")