diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd index 55fe060..5eb8aab 100644 --- a/client/tests/unit/test_character_creation_screen.gd +++ b/client/tests/unit/test_character_creation_screen.gd @@ -60,7 +60,25 @@ func test_screen_applies_theme_and_fits_viewport(): var s := _screen() assert_true(s is Control) assert_true(s.theme is Theme, "the screen pulls the shared theme") - assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)") + + # The .tscn root is authored at exactly 1920x1080 (project.godot's viewport + # size too) — assert_gt(s.size.x, 0.0) passes on the AUTHORED offsets alone, + # even with _fit_to_viewport() and its size_changed connection deleted + # outright. Prove the fit is actually live: change the window's reported + # resolution to something that is NOT 1920x1080 and assert the root followed. + # + # get_viewport().size / get_window().size do not move get_viewport_rect() — + # this project runs with window/stretch/mode="canvas_items", so a Control's + # visible rect tracks content_scale_size, not the raw window pixel size. + # content_scale_size is the property that actually reaches Viewport.size_changed + # here, headless included. + var win := s.get_window() + var original_scale_size: Vector2i = win.content_scale_size + win.content_scale_size = Vector2i(1600, 900) + assert_eq(s.size, Vector2(1600, 900), + "the root must follow a resize through _fit_to_viewport()'s size_changed hook (the M3-a collapse guard)") + win.content_scale_size = original_scale_size + assert_eq(s.size, Vector2(1920, 1080), "restoring the viewport must restore the root, or later tests see a stale size") func test_a_draft_exists_with_a_seed_on_load(): @@ -127,10 +145,32 @@ func test_no_node_on_the_screen_says_luck(): # `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) + # + # But _bind_bay() and _bind_callings() both wrap their prose in BBCode + # italics ("[i]%s[/i]"), and _origin_prose() always emits the literal + # connective " Now you carry a %s's work — " even when every fragment is + # blank. So `"[i][/i]".strip_edges().is_empty()` is FALSE — the wrapper + # alone would pass this guard even if ContentDB served nothing at all. + # Assert the labels render the WORLD's own strings, not just non-blankness. + var race_id: String = Races.IDS[race_i] + var calling_id: String = allowed[calling_i] + var race_frag: String = str(s.world.race(race_id).get("fragment", s.world.race(race_id).get("blurb", ""))) + var calling_frag: String = str(s.world.calling(calling_id).get("fragment", s.world.calling(calling_id).get("blurb", ""))) + var calling_blurb: String = str(s.world.calling(calling_id).get("blurb", "")) + + assert_false(race_frag.strip_edges().is_empty(), + "race %s has no fragment (or blurb) to render — the content itself must not be blank" % race_id) + assert_false(calling_frag.strip_edges().is_empty(), + "calling %s has no fragment (or blurb) to render — the content itself must not be blank" % calling_id) + assert_false(calling_blurb.strip_edges().is_empty(), + "calling %s has no blurb to render — the content itself must not be blank" % calling_id) + + assert_true(s._origin_text.text.contains(race_frag), + "the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [race_id, combo]) + assert_true(s._origin_text.text.contains(calling_frag), + "the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [calling_id, combo]) + assert_true(s._detail_blurb.text.contains(calling_blurb), + "the detail panel must render %s's own blurb, not just non-empty BBCode, at %s" % [calling_id, combo]) assert_true(checked_a_rich_text_label, "the sweep must actually reach the RichTextLabels or it is checking nothing") @@ -384,3 +424,87 @@ func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool(): 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") + + +func test_a_race_granted_skill_chip_is_inert_and_so_is_a_chip_once_picks_are_spent(): + # Two real binder behaviours in one chip's line + # (chip.disabled = draft.is_granted(skill) or (not is_picked(skill) and not can_pick(skill))) + # that nothing in the suite observed. Elf grants "perception"; Cutpurse's pool + # has 6 skills (one of them "perception") and needs 4 picks — the only + # race/calling pairing where both halves of that OR are exercised at once. + var s := _screen() + s._race_cards[Races.IDS.find("elf")].pressed.emit() + var cutpurse_i: int = s._allowed_callings().find("cutpurse") + s._calling_cards[cutpurse_i].pressed.emit() + + var pool: Array = s.draft.skill_pool() + var perception_i: int = pool.find("perception") + assert_ne(perception_i, -1, "cutpurse's pool must include perception for this test to mean anything") + assert_true(s._pool_chips[perception_i].disabled, + "a race-granted skill must be inert — the player already has it for free") + + # Spend every pick on the OTHER skills in the pool, leaving exactly one behind. + var picked := 0 + var left_behind_i := -1 + for i in range(pool.size()): + if i == perception_i: + continue + if picked < s.draft.picks_needed(): + s._pool_chips[i].pressed.emit() + picked += 1 + else: + left_behind_i = i + assert_eq(picked, s.draft.picks_needed(), "the pool must actually be spent for this test to mean anything") + assert_ne(left_behind_i, -1, "cutpurse's pool must be wider than its pick count for this test to mean anything") + assert_true(s._pool_chips[left_behind_i].disabled, + "once the picks are spent, the remaining UNPICKED chip must go inert too") + assert_false(s._pool_chips[perception_i].button_pressed, + "the granted chip was never toggled — it was inert from the start") + + +func test_bonus_row_shows_only_for_the_race_that_wants_one(): + # _bonus_row.visible = draft.wants_bonus_skill() — nothing in the suite checks + # this. Human wants a bonus skill; the other three races do not. + var s := _screen() # default race is human + assert_true(s._bonus_row.visible, "human wants a bonus skill choice") + + for race_id in ["elf", "dwarf", "beastfolk"]: + s._race_cards[Races.IDS.find(race_id)].pressed.emit() + assert_false(s._bonus_row.visible, "%s does not want a bonus skill choice" % race_id) + + s._race_cards[Races.IDS.find("human")].pressed.emit() + assert_true(s._bonus_row.visible, "switching back to a bonus-wanting race must show the row again") + + +func test_ability_card_surfaces_the_calling_s_primary_stat(): + # The RACE card's Tag is checked (test_pressing_a_race_card_sets_the_draft_s_race); + # the ability card's equivalent — Tag.visible and theme_type_variation marking + # which of the five stats is the calling's primary — is not. + var s := _screen() + var idx: int = s._allowed_callings().find("cutpurse") + s._calling_cards[idx].pressed.emit() + var primary: String = Callings.primary("cutpurse") + assert_eq(primary, "dex", "cutpurse's primary must be dex for this test to mean anything") + + for i in range(Attributes.IDS.size()): + var stat: String = Attributes.IDS[i] + var card: PanelContainer = s._ability_cards[i] + var is_primary := (stat == primary) + assert_eq(card.get_node("Tag").visible, is_primary, + "%s's Tag must show only when it is the calling's primary stat" % stat) + var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.PARCHMENT_CARD + assert_eq(card.theme_type_variation, expected_variation, + "%s's card style must reflect whether it is the primary stat" % stat) + + +func test_pressing_enter_on_an_illegal_draft_emits_nothing(): + # _on_enter_pressed's early-out (`if not draft.is_complete(): return`) is the + # one branch in the file nothing else in the suite enters — every other Enter + # test first drives the draft to a legal state. Button.pressed still fires even + # on a disabled Button, so the early-out must be proven explicitly. + var s := _screen() + assert_true(s._enter.disabled, "an empty draft must start illegal, or this test proves nothing") + watch_signals(s) + s._enter.pressed.emit() + assert_signal_not_emitted(s, "creation_confirmed", + "an illegal draft must never reach creation_confirmed, even when Enter fires anyway")