Files
settled-reach/client/tests/test_character_creation_sprint28.gd
T
jpmschweitzerandClaude Opus 4.6 35858fa0fa feat(ui): Bookmark tab content + CharacterProfile signal payload (Workstream 6)
Fills in the Bookmark tab stubbed in W5 with the full spec from
Araminta: card list + detail view + start-button gating. Also changes
the creation_confirmed signal to carry a CharacterProfile instead of
bare CharacterVisualDescriptor, consolidating bookmark + location
selection into one payload object.

Bookmark tab (left pane, 35%):
- ScrollContainer over VBoxContainer of card Buttons, one per entry in
  GameState.bookmark_catalog. Each card: title (PRIMARY_TEXT,
  font_header 15px) / subtitle (DIM_TEXT, font_small 10px, clipped) /
  career badge (ACCENT_ACTIVE, all-caps). Selected state uses existing
  ITEM_SELECTED_BG + ITEM_SELECTED_BORDER. custom_minimum_size
  Vector2(180, 64).

Detail view (right pane, 65%):
- ImplantPanel composed via add_component:
  - ImplantHeader (bookmark.title, bookmark.subtitle)
  - ImplantSeparator
  - ImplantTextBlock (flavor, autowrap, PRIMARY_TEXT)
  - ImplantSeparator
  - ImplantDataRow CAREER (accent_active) / CAPITAL (accent_positive,
    format "%d Tractus") / STARTING LOCATION
  - ImplantSeparator
  - [location picker space reserved — W7 fills it]

Selection:
- Card click stores _selected_bookmark_id, auto-assigns
  _selected_location_id from bookmark.default_location, rebuilds
  detail view.
- Start button (footer) gated on both _selected_bookmark_id and
  _selected_location_id non-empty.
- Randomize while Bookmark tab is active picks a random bookmark +
  one of its allowed_locations and skips appearance randomization.

Signal contract change:
- creation_confirmed(profile: CharacterProfile) replaces
  creation_confirmed(descriptor: CharacterVisualDescriptor).
- CharacterProfile now extends RefCounted (was Resource) with
  non-exported fields — it's a one-shot signal payload, never
  persisted. This also sidesteps the scanner error that the prior
  @export var descriptor: CharacterVisualDescriptor on a Resource
  caused (RefCounted types cannot be @export-ed).
- _on_start emits a CharacterProfile built from _descriptor +
  _selected_bookmark_id + _selected_location_id, then sends
  ConfirmBookmark via SimBridge.send_named_action before scene
  transition.

Test updates:
- test_character_creation_sprint28.gd signal receivers switched to
  untyped to accept CharacterProfile without hitting class_name
  parse-order at test-suite scan time. 88/88 pass.

Verification:
- gdlint clean
- godot --headless --path client --quit — no SCRIPT ERROR (prior
  character_profile.gd scanner noise now gone after the RefCounted
  conversion)
- test_character_creation_sprint28 88/88, test_protocol 62/62,
  test_implant_nav_stack 52/52

Workstream 7 (location picker as sub-component of the Bookmark detail
view) follows. W8 fills the Skills tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 19:36:00 +02:00

637 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_profile = null # CharacterProfile — untyped avoids parse-time member resolution
_scene.creation_confirmed.connect(func(p): received_profile = p)
_scene._on_start()
assert_bool(received_profile != null).override_failure_message(
"creation_confirmed must pass a CharacterProfile"
).is_true()
assert_bool(received_profile is CharacterProfile).is_true()
# =============================================================================
# Descriptor defaults (Task #1)
# =============================================================================
func test_descriptor_initialized_on_ready() -> void:
if _scene == null:
return
var received_profile = null # CharacterProfile — untyped avoids parse-time member resolution
_scene.creation_confirmed.connect(func(p): received_profile = p)
_scene._on_start()
assert_bool(received_profile != null).is_true()
if received_profile == null:
return
var desc = received_profile.descriptor
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 received_profile = null # CharacterProfile — untyped avoids parse-time member resolution
_scene.creation_confirmed.connect(func(p): received_profile = p)
_scene._on_start()
if received_profile == null:
return
assert_int(received_profile.descriptor.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 received_profile = null # CharacterProfile — untyped avoids parse-time member resolution
_scene.creation_confirmed.connect(func(p): received_profile = p)
_scene._on_start()
if received_profile == null:
return
assert_int(received_profile.descriptor.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()