Files
settled-reach/client/tests/test_character_creation_sprint28.gd
T
jpmschweitzerandClaude Opus 4.6 145d8fec17 refactor(client): remove archetype selection, fix Randomise→Randomize
- Remove character_select.tscn and character_select.gd entirely
  (Smuggler/Detective archetypes removed per v0.2 pivot)
- New Game goes straight to character creation
- Rename Randomise→Randomize throughout (US English convention)
- Add *.import and *.uid to client gitignore (import artifacts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:18:47 +01:00

633 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 08"
).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 08"
).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()