## Sprint 28 — Character creation screen tests (#705, Task #9) ## ## Validates the CharacterCreation UI: scene instantiation, tab structure, ## signal emission (creation_confirmed / creation_cancelled), keyboard nav ## callbacks, randomize, color derivation helpers, and game flow wiring. ## ## These are UI-only tests (no compositor/server required). ## CharacterVisual asset paths fall back gracefully when GLBs are absent. ## ## Ticket: #705 | D-146, D-155, D-158, D-159, D-165 class_name TestCharacterCreationSprint28 extends GdUnitTestSuite const SCENE_PATH := "res://scenes/character_creation.tscn" var _scene: CharacterCreation = null func before_each() -> void: var packed := load(SCENE_PATH) as PackedScene if packed == null: # Skip all tests gracefully if scene not loadable in headless env return _scene = packed.instantiate() as CharacterCreation add_child(_scene) func after_each() -> void: if is_instance_valid(_scene): _scene.queue_free() _scene = null # ============================================================================= # Scene instantiation (Task #1) # ============================================================================= func test_scene_instantiates() -> void: if _scene == null: return assert_bool(is_instance_valid(_scene)).override_failure_message( "character_creation.tscn must instantiate successfully" ).is_true() func test_scene_is_character_creation_class() -> void: if _scene == null: return assert_bool(_scene is CharacterCreation).override_failure_message( "instantiated scene must be a CharacterCreation node" ).is_true() func test_tab_container_has_five_tabs() -> void: if _scene == null: return var tc: TabContainer = _scene.get_node_or_null("Layout/TabPanel/TabContainer") assert_bool(tc != null).override_failure_message("TabContainer must exist").is_true() if tc == null: return assert_int(tc.get_tab_count()).override_failure_message( "TabContainer must have exactly 5 tabs (Body/Head/Hair/Clothing/Accessories)" ).is_equal(5) func test_tab_names() -> void: if _scene == null: return var tc: TabContainer = _scene.get_node_or_null("Layout/TabPanel/TabContainer") if tc == null: return var expected := ["Body", "Head", "Hair", "Clothing", "Accessories"] for i in expected.size(): assert_str(tc.get_tab_title(i)).override_failure_message( "Tab %d must be named '%s'" % [i, expected[i]] ).is_equal(expected[i]) func test_subviewport_exists() -> void: if _scene == null: return var sv: SubViewport = _scene.get_node_or_null( "Layout/PreviewPanel/SubViewportContainer/SubViewport" ) assert_bool(sv != null).override_failure_message("SubViewport must exist in scene tree").is_true() func test_subviewport_always_updates() -> void: if _scene == null: return var sv: SubViewport = _scene.get_node_or_null( "Layout/PreviewPanel/SubViewportContainer/SubViewport" ) if sv == null: return # render_target_update_mode = 3 = ALWAYS (live 3D preview) assert_int(sv.render_target_update_mode).override_failure_message( "SubViewport must use ALWAYS update mode (3) for live preview" ).is_equal(3) func test_preview_camera_exists() -> void: if _scene == null: return var cam: Camera3D = _scene.get_node_or_null( "Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera" ) assert_bool(cam != null).override_failure_message("PreviewCamera must exist in SubViewport").is_true() if cam == null: return assert_bool(cam.current).override_failure_message("PreviewCamera must be active (current=true)").is_true() func test_footer_buttons_exist() -> void: if _scene == null: return var back: Button = _scene.get_node_or_null("Footer/BackBtn") var rand_btn: Button = _scene.get_node_or_null("Footer/RandomizeBtn") var start: Button = _scene.get_node_or_null("Footer/StartBtn") assert_bool(back != null).override_failure_message("BackBtn must exist in Footer").is_true() assert_bool(rand_btn != null).override_failure_message("RandomizeBtn must exist in Footer").is_true() assert_bool(start != null).override_failure_message("StartBtn must exist in Footer").is_true() # ============================================================================= # Signal emission (Tasks #1 and #8) # ============================================================================= func test_creation_cancelled_emits_on_back() -> void: if _scene == null: return var monitor := monitor_signals(_scene) _scene._on_back() assert_signal(monitor).is_emitted("creation_cancelled") func test_creation_confirmed_emits_on_start() -> void: if _scene == null: return var monitor := monitor_signals(_scene) _scene._on_start() assert_signal(monitor).is_emitted("creation_confirmed") func test_creation_confirmed_carries_descriptor() -> void: if _scene == null: return var received_descriptor: CharacterVisualDescriptor = null _scene.creation_confirmed.connect(func(d): received_descriptor = d) _scene._on_start() assert_bool(received_descriptor != null).override_failure_message( "creation_confirmed must pass a CharacterVisualDescriptor" ).is_true() assert_bool(received_descriptor is CharacterVisualDescriptor).is_true() # ============================================================================= # Descriptor defaults (Task #1) # ============================================================================= func test_descriptor_initialized_on_ready() -> void: if _scene == null: return var desc: CharacterVisualDescriptor = null _scene.creation_confirmed.connect(func(d): desc = d) _scene._on_start() assert_bool(desc != null).is_true() if desc == null: return # Should default to a non-Child body type assert_bool(desc.body_type != CharacterVisualDescriptor.BodyType.CHILD).override_failure_message( "Default body type should not be Child" ).is_true() # Skin tone should be in valid range assert_bool(desc.skin_tone >= 0 and desc.skin_tone <= 8).override_failure_message( "skin_tone must be 0–8" ).is_true() # ============================================================================= # Rotation (Task #1, D-155) # ============================================================================= func test_rotate_left_changes_facing() -> void: if _scene == null: return var initial := _scene._facing_idx _scene._on_rotate_left() assert_int(_scene._facing_idx).override_failure_message( "Q/rotate-left must advance facing_idx" ).is_not_equal(initial) func test_rotate_right_changes_facing() -> void: if _scene == null: return var initial := _scene._facing_idx _scene._on_rotate_right() assert_int(_scene._facing_idx).override_failure_message( "E/rotate-right must change facing_idx" ).is_not_equal(initial) func test_four_rotations_return_to_original() -> void: if _scene == null: return var initial := _scene._facing_idx _scene._on_rotate_left() _scene._on_rotate_left() _scene._on_rotate_left() _scene._on_rotate_left() assert_int(_scene._facing_idx).override_failure_message( "4 left rotations must return to original facing" ).is_equal(initial) # ============================================================================= # Camera angle toggle (Task #1, D-158) # ============================================================================= func test_cam_angle_cycles_three_presets() -> void: if _scene == null: return var initial := _scene._cam_pitch_idx _scene._on_cam_angle_toggle() _scene._on_cam_angle_toggle() _scene._on_cam_angle_toggle() assert_int(_scene._cam_pitch_idx).override_failure_message( "3 cam angle toggles must return to original preset" ).is_equal(initial) func test_cam_angle_default_is_frontal() -> void: if _scene == null: return # frontal = index 0, pitch -5° assert_int(_scene._cam_pitch_idx).override_failure_message( "Default camera must be frontal (index 0, -5° per D-158)" ).is_equal(0) # ============================================================================= # Body type selection (Task #2, D-159) # ============================================================================= func test_body_type_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_body_type_selected(CharacterVisualDescriptor.BodyType.THIN_F) var desc: CharacterVisualDescriptor = null _scene.creation_confirmed.connect(func(d): desc = d) _scene._on_start() if desc == null: return assert_int(desc.body_type as int).override_failure_message( "Selecting THIN_F must update descriptor.body_type" ).is_equal(CharacterVisualDescriptor.BodyType.THIN_F) func test_all_11_body_types_selectable() -> void: if _scene == null: return # Verify all 11 body types can be selected without error and update descriptor var all_types := CharacterVisualDescriptor.BodyType.values() assert_int(all_types.size()).override_failure_message( "Must have 11 body types per D-159" ).is_equal(11) for bt in all_types: _scene._on_body_type_selected(bt) assert_int(_scene._descriptor.body_type as int).override_failure_message( "Body type selection must update descriptor immediately" ).is_equal(bt) # ============================================================================= # Skin tone (Tasks #2/#3, shared state) # ============================================================================= func test_skin_tone_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_skin_tone_selected(5) var desc: CharacterVisualDescriptor = null _scene.creation_confirmed.connect(func(d): desc = d) _scene._on_start() if desc == null: return assert_int(desc.skin_tone).override_failure_message( "Selecting skin tone index 5 must update descriptor.skin_tone" ).is_equal(5) func test_skin_tone_range_valid() -> void: if _scene == null: return # All 9 indices must be selectable without error for i in range(9): _scene._on_skin_tone_selected(i) assert_int(_scene._descriptor.skin_tone).is_equal(8) # last selected # ============================================================================= # Hair selection (Task #4) # ============================================================================= func test_hair_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_hair_selected("short_b") assert_str(_scene._descriptor.hair_id).override_failure_message( "Selecting hair 'short_b' must update descriptor.hair_id" ).is_equal("short_b") func test_facial_hair_none_clears_id() -> void: if _scene == null: return _scene._on_facial_hair_selected("beard") assert_str(_scene._descriptor.facial_hair_id).is_equal("beard") _scene._on_facial_hair_selected("") assert_str(_scene._descriptor.facial_hair_id).is_equal("") func test_eyebrow_selection_updates_descriptor() -> void: if _scene == null: return _scene._on_eyebrow_selected("thick") assert_str(_scene._descriptor.eyebrow_id).override_failure_message( "Selecting eyebrow 'thick' must update descriptor.eyebrow_id" ).is_equal("thick") # ============================================================================= # Color derivation helpers (Task #4/#5, D-165) # ============================================================================= func test_derive_hair_highlight_lightens_primary() -> void: if _scene == null: return var primary := Color(0.4, 0.2, 0.1) var highlight := _scene._derive_hair_highlight(primary) # Highlight should be lighter than primary assert_bool(highlight.v > primary.v).override_failure_message( "Hair highlight must be lighter than primary color" ).is_true() func test_derive_secondary_darkens_primary() -> void: if _scene == null: return var primary := Color(0.6, 0.5, 0.4) var secondary := _scene._derive_secondary(primary) assert_bool(secondary.v < primary.v).override_failure_message( "Secondary color must be darker than primary" ).is_true() func test_derive_accent_shifts_hue() -> void: if _scene == null: return var primary := Color.from_hsv(0.0, 0.5, 0.7) var accent := _scene._derive_accent(primary) # Accent hue should differ from primary hue assert_bool(abs(accent.h - primary.h) > 0.0).override_failure_message( "Accent must have shifted hue from primary" ).is_true() # ============================================================================= # Randomize (Task #8) # ============================================================================= func test_randomize_changes_body_type() -> void: if _scene == null: return # Run randomize multiple times to ensure it doesn't always pick the default var changed := false var original := _scene._descriptor.body_type for _i in 20: _scene._on_randomize() if _scene._descriptor.body_type != original: changed = true break assert_bool(changed).override_failure_message( "Randomize must be able to change body type from default" ).is_true() func test_randomize_produces_valid_skin_tone() -> void: if _scene == null: return for _i in 10: _scene._on_randomize() var tone := _scene._descriptor.skin_tone assert_bool(tone >= 0 and tone <= 8).override_failure_message( "Randomize must produce skin_tone in range 0–8" ).is_true() # ============================================================================= # Tab keyboard navigation (Task #8) # ============================================================================= func test_tab_navigation_wraps() -> void: if _scene == null: return var tc: TabContainer = _scene.get_node_or_null("Layout/TabPanel/TabContainer") if tc == null: return tc.current_tab = 4 # last tab # Simulate Tab key forward — wraps to 0 var event := InputEventKey.new() event.keycode = KEY_TAB event.pressed = true event.shift_pressed = false _scene._input(event) assert_int(tc.current_tab).override_failure_message( "Tab key on last tab must wrap to first tab" ).is_equal(0) func test_shift_tab_navigates_backward() -> void: if _scene == null: return var tc: TabContainer = _scene.get_node_or_null("Layout/TabPanel/TabContainer") if tc == null: return tc.current_tab = 2 var event := InputEventKey.new() event.keycode = KEY_TAB event.pressed = true event.shift_pressed = true _scene._input(event) assert_int(tc.current_tab).override_failure_message( "Shift+Tab must navigate to previous tab" ).is_equal(1) func test_esc_emits_cancelled() -> void: if _scene == null: return var monitor := monitor_signals(_scene) var event := InputEventKey.new() event.keycode = KEY_ESCAPE event.pressed = true _scene._input(event) assert_signal(monitor).is_emitted("creation_cancelled") func test_enter_emits_confirmed() -> void: if _scene == null: return var monitor := monitor_signals(_scene) var event := InputEventKey.new() event.keycode = KEY_ENTER event.pressed = true _scene._input(event) assert_signal(monitor).is_emitted("creation_confirmed") func test_q_key_rotates_left() -> void: if _scene == null: return var initial := _scene._facing_idx var event := InputEventKey.new() event.keycode = KEY_Q event.pressed = true _scene._input(event) assert_int(_scene._facing_idx).override_failure_message( "Q key must rotate character (facing_idx change)" ).is_not_equal(initial) func test_r_key_triggers_randomize() -> void: if _scene == null: return # Seed to a known state so change is detectable _scene._descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M _scene._descriptor.skin_tone = 0 var body_before := _scene._descriptor.body_type var skin_before := _scene._descriptor.skin_tone # Press R up to 10 times — must change at least one observable descriptor field var changed := false for _i in 10: var event := InputEventKey.new() event.keycode = KEY_R event.pressed = true _scene._input(event) if _scene._descriptor.body_type != body_before or _scene._descriptor.skin_tone != skin_before: changed = true break assert_bool(changed).override_failure_message( "R key must trigger randomize and change body_type or skin_tone" ).is_true() # ============================================================================= # Color picker modal (Task #7, D-165) # ============================================================================= func test_color_picker_modal_hidden_by_default() -> void: if _scene == null: return var modal: Control = _scene.get_node_or_null("ColorPickerModal") assert_bool(modal != null).override_failure_message("ColorPickerModal node must exist").is_true() if modal == null: return assert_bool(not modal.visible).override_failure_message( "ColorPickerModal must be hidden at startup" ).is_true() func test_open_color_picker_shows_modal() -> void: if _scene == null: return var modal: Control = _scene.get_node_or_null("ColorPickerModal") if modal == null: return var received: Color = Color.WHITE _scene._open_color_picker(Color.RED, null, func(c): received = c) assert_bool(modal.visible).override_failure_message( "_open_color_picker must make modal visible" ).is_true() # Cancel to clean up _scene._on_modal_cancel() func test_modal_cancel_hides_modal() -> void: if _scene == null: return var modal: Control = _scene.get_node_or_null("ColorPickerModal") if modal == null: return _scene._open_color_picker(Color.BLUE, null, func(_c): pass) _scene._on_modal_cancel() assert_bool(not modal.visible).override_failure_message( "Cancel must hide the modal" ).is_true() func test_modal_cancel_reverts_to_original() -> void: if _scene == null: return var received: Color = Color.WHITE var original := Color.RED _scene._open_color_picker(original, null, func(c): received = c) # Simulate palette swatch press (changes color) _scene._on_modal_palette_swatch_pressed(Color.GREEN, null) # Cancel — must revert to original _scene._on_modal_cancel() assert_bool(received.is_equal_approx(original)).override_failure_message( "Cancel must revert callback to original color" ).is_true() func test_modal_ok_closes_modal() -> void: if _scene == null: return var modal: Control = _scene.get_node_or_null("ColorPickerModal") if modal == null: return _scene._open_color_picker(Color.WHITE, null, func(_c): pass) _scene._on_modal_ok() assert_bool(not modal.visible).override_failure_message( "OK must hide the modal" ).is_true() func test_modal_esc_key_cancels_when_open() -> void: if _scene == null: return var modal: Control = _scene.get_node_or_null("ColorPickerModal") if modal == null: return _scene._open_color_picker(Color.WHITE, null, func(_c): pass) assert_bool(modal.visible).is_true() var event := InputEventKey.new() event.keycode = KEY_ESCAPE event.pressed = true _scene._input(event) assert_bool(not modal.visible).override_failure_message( "ESC must cancel and close the modal when open" ).is_true() # ============================================================================= # Palette generation (D-165) # ============================================================================= func test_palette_has_54_swatches() -> void: if _scene == null: return assert_int(_scene._modal_swatch_btns.size()).override_failure_message( "D-165 palette must have 54 swatches (6 rows × 9 cols)" ).is_equal(54) func test_recent_colors_slots() -> void: if _scene == null: return assert_int(_scene._modal_recent_btns.size()).override_failure_message( "Recent colors must have 9 slots per D-165" ).is_equal(9) # ============================================================================= # Search filter helper # ============================================================================= func test_search_filter_hides_non_matching_buttons() -> void: if _scene == null: return var grid := GridContainer.new() add_child(grid) var btn_a := Button.new() btn_a.text = "Alpha" grid.add_child(btn_a) var btn_b := Button.new() btn_b.text = "Beta" grid.add_child(btn_b) _scene._apply_search_filter(grid, "alp") assert_bool(btn_a.visible).override_failure_message("'Alpha' should be visible for query 'alp'").is_true() assert_bool(not btn_b.visible).override_failure_message("'Beta' should be hidden for query 'alp'").is_true() _scene._apply_search_filter(grid, "") assert_bool(btn_a.visible).override_failure_message("All buttons visible with empty query").is_true() assert_bool(btn_b.visible).override_failure_message("All buttons visible with empty query").is_true() grid.queue_free()