diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd index 7ba6308..ce717b3 100644 --- a/client/scripts/ui/creation/character_creation.gd +++ b/client/scripts/ui/creation/character_creation.gd @@ -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() diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 2304b3d..03e9235 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -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)