Files
settled-reach/client/tests/test_character_visual_sprint28.gd
T
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
Fix 354 gdlint warnings across 65 files: 194 class-definitions-order
(reorder declarations), 138 max-line-length (split long lines),
22 code issues (unused args, no-else-return, naming). Update .gdlintrc
to exclude addons/ and raise max-public-methods for test files.
No logic changes — declaration order, whitespace, and naming only.

Ticket: #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:56 +02:00

557 lines
21 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 — CharacterVisual compositor tests (#704)
##
## Validates the character visual assembler (CharacterVisual.gd) against the
## architectural spec at docs/architecture/character-asset-organization.md.
##
## Most tests are test-first stubs: they push_warning and return when the
## compositor class or required asset files are not yet present. Once Stig's
## implementation lands, the guards are removed automatically.
##
## Test areas (per team lead focus list):
## 1. Descriptor-to-node-tree construction
## 2. Segment visibility toggling for clothing coverage
## 3. Torso variant swap (seg_torso vs seg_torso_upper)
## 4. Tint application (skin tone index → texture)
## 5. Facing rotation (set_facing / rotation change)
## 6. Edge cases: empty hair_id, no clothing, no accessories
##
## Spec: D-159 (body types), D-160 (17 bone-group regions), D-161 (head on Head bone),
## D-162 (clothing pre-baked per body type), D-164 (fork skeleton)
## Ticket: #704
class_name TestCharacterVisualSprint28
extends GdUnitTestSuite
const COMPOSITOR_PATH := "res://scripts/rendering/character_visual.gd"
const SKIN_TONES_DIR := "res://assets/characters/skin_tones/"
const BODIES_DIR := "res://assets/characters/bodies/"
const SKELETON_PATH := "res://assets/characters/skeleton/armature.glb"
## Returns a minimal valid descriptor with no optional layers.
func _make_minimal_descriptor() -> CharacterVisualDescriptor:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
d.head_id = "head_001"
d.hair_id = "" # empty = no hair
d.facial_hair_id = "" # empty = none
d.eyebrow_id = "" # empty = no eyebrows
d.skin_tone = 0
return d
## Returns true if the compositor script is present and loadable.
func _compositor_available() -> bool:
if not ResourceLoader.exists(COMPOSITOR_PATH):
push_warning("TestCharacterVisualSprint28: character_visual.gd not found — test-first stub (awaiting #704)")
return false
return true
## Returns true if the skeleton GLB is present (required for segment/clothing tests).
func _skeleton_available() -> bool:
return ResourceLoader.exists(SKELETON_PATH)
## Loads and instantiates a CharacterVisual node. Returns null with warning if unavailable.
func _make_compositor() -> Node:
if not _compositor_available():
return null
var script: GDScript = load(COMPOSITOR_PATH)
if script == null:
push_warning("TestCharacterVisualSprint28: failed to load character_visual.gd")
return null
var node := Node3D.new()
node.set_script(script)
add_child(node)
return node
func after_test() -> void:
# Clean up any nodes added during testing
for child in get_children():
if child != self:
child.queue_free()
# =============================================================================
# 1. API presence — compositor class exists and has expected public interface
# =============================================================================
func test_compositor_script_file_exists() -> void:
# Gate: is the file present at all?
if not ResourceLoader.exists(COMPOSITOR_PATH):
push_warning("TestCharacterVisualSprint28: character_visual.gd missing — test-first stub")
return
assert_bool(ResourceLoader.exists(COMPOSITOR_PATH)).is_true()
func test_compositor_has_load_descriptor_method() -> void:
var node := _make_compositor()
if node == null:
return
assert_bool(node.has_method("load_descriptor")).override_failure_message(
"CharacterVisual must expose load_descriptor(desc: CharacterVisualDescriptor)"
).is_true()
func test_compositor_has_set_facing_method() -> void:
var node := _make_compositor()
if node == null:
return
assert_bool(node.has_method("set_facing")).override_failure_message(
"CharacterVisual must expose set_facing(direction: Vector2 or int)"
).is_true()
func test_compositor_is_node3d() -> void:
# Compositor must be a Node3D (3D scene tree, not 2D)
if not _compositor_available():
return
var script: GDScript = load(COMPOSITOR_PATH)
var node := Node3D.new()
node.set_script(script)
add_child(node)
assert_bool(node is Node3D).override_failure_message(
"CharacterVisual must extend Node3D"
).is_true()
# =============================================================================
# 2. Descriptor-to-node-tree construction
# =============================================================================
func test_load_descriptor_does_not_crash_with_minimal_desc() -> void:
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
# Must not crash — assets may be missing but the call must not throw
node.load_descriptor(desc)
assert_bool(true).is_true() # If we got here, no crash
func test_load_descriptor_produces_child_nodes() -> void:
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
node.load_descriptor(desc)
# A fully loaded compositor must have at least one child (body skeleton or armature)
# Guard: skip if assets genuinely not present yet (real test once assets land)
if node.get_child_count() == 0:
push_warning("TestCharacterVisualSprint28: no child nodes after load_descriptor — assets may not be imported yet")
return
assert_bool(node.get_child_count() > 0).is_true()
func test_reload_descriptor_clears_previous_nodes() -> void:
# Loading a second descriptor must replace the first, not accumulate nodes
var node := _make_compositor()
if node == null:
return
var desc1 := _make_minimal_descriptor()
desc1.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
var desc2 := _make_minimal_descriptor()
desc2.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
node.load_descriptor(desc1)
var count_after_first := node.get_child_count()
node.load_descriptor(desc2)
var count_after_second := node.get_child_count()
if count_after_first == 0:
push_warning("TestCharacterVisualSprint28: node count 0 — assets may not be present yet")
return
# Second load must produce exactly the same child count — no more, no less.
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
assert_int(count_after_second).override_failure_message(
"load_descriptor must produce identical child count on reload" +
" — stale nodes detected if higher, missing cleanup if lower"
).is_equal(count_after_first)
# =============================================================================
# 3. Segment visibility toggling for clothing coverage
# =============================================================================
func test_no_clothing_all_body_segments_visible() -> void:
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {} # no clothing
node.load_descriptor(desc)
# All non-swapped segments should be visible with no clothing.
# is_segment_visible returns false for missing segments — only meaningful when skeleton loaded.
assert_bool(node.is_segment_visible("torso")).override_failure_message(
"seg_torso must be visible when no clothing covers it"
).is_true()
assert_bool(node.is_segment_visible("arm_upper_l")).is_true()
assert_bool(node.is_segment_visible("leg_upper_l")).is_true()
func test_clothing_hides_declared_segments() -> void:
# A full coverall with hides = [torso, arm_upper_l, arm_upper_r, ...] must
# hide those segments
var node := _make_compositor()
if node == null:
return
if not node.has_method("is_segment_visible"):
push_warning("TestCharacterVisualSprint28: is_segment_visible not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "coveralls_basic"}
node.load_descriptor(desc)
# Edge case: coverage.json may not exist yet — guard
if not node.has_method("get_active_coverage"):
push_warning("TestCharacterVisualSprint28: coverage integration not yet wired — skipping segment hide check")
return
# Coveralls should hide the torso segment (among others)
# The exact hidden set is declared in coverage.json — we test the contract, not the file content
var coverage: Dictionary = node.get_active_coverage("coveralls_basic")
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
return
for hidden_seg: String in coverage.get("hides", []):
assert_bool(node.is_segment_visible(hidden_seg)).override_failure_message(
"Segment '%s' declared in coverage.json hides must not be visible" % hidden_seg
).is_false()
func test_segments_not_in_hides_remain_visible() -> void:
# A jacket (upper body only) must NOT hide leg segments
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "jacket_utility"} # upper body only
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("jacket_utility") if node.has_method("get_active_coverage") else {}
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: jacket_utility/coverage.json not found — stub")
return
var hides: Array = coverage.get("hides", [])
if not "leg_upper_l" in hides:
# Jacket doesn't cover legs — legs must still be visible
assert_bool(node.is_segment_visible("leg_upper_l")).override_failure_message(
"Leg segments must remain visible when only a jacket is worn"
).is_true()
# =============================================================================
# 4. Torso variant swap
# =============================================================================
func test_torso_variant_full_uses_seg_torso() -> void:
# coverage.json torso_variant: "full" → seg_torso is loaded (default)
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_active_torso_variant"):
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub (awaiting #704)")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {} # no clothing → default torso
node.load_descriptor(desc)
assert_str(node.get_active_torso_variant()).override_failure_message(
"With no clothing, torso variant must be 'full' (uses seg_torso)"
).is_equal("full")
func test_torso_variant_upper_uses_seg_torso_upper() -> void:
# coverage.json torso_variant: "upper" → seg_torso_upper is loaded
# (e.g. tank top: exposes lower abdomen)
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_active_torso_variant"):
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "shirt_tank"} # a tank top with torso_variant: "upper"
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("shirt_tank") if node.has_method("get_active_coverage") else {}
if coverage.is_empty() or coverage.get("torso_variant") != "upper":
push_warning("TestCharacterVisualSprint28: shirt_tank/coverage.json not found or no upper variant — stub")
return
assert_str(node.get_active_torso_variant()).override_failure_message(
"Tank top with torso_variant:'upper' must load seg_torso_upper"
).is_equal("upper")
func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
# A full coverall hides the torso segment entirely (not swapped to upper)
var node := _make_compositor()
if node == null:
return
if not _skeleton_available():
push_warning("TestCharacterVisualSprint28: skeleton not found — torso hide test requires #706 assets")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {"torso": "coveralls_basic"}
node.load_descriptor(desc)
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") \
if node.has_method("get_active_coverage") else {}
if coverage.is_empty():
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
return
if "torso" in coverage.get("hides", []):
# Full coverage: torso segment is hidden, not swapped
assert_bool(node.is_segment_visible("torso")).override_failure_message(
"Full-coverage clothing with 'torso' in hides must hide seg_torso entirely"
).is_false()
# =============================================================================
# 5. Facing rotation
# =============================================================================
func test_set_facing_changes_node_rotation() -> void:
var node := _make_compositor()
if node == null:
return
# set_facing only writes rotation.y — no asset dependency, no load_descriptor needed
var rot_before: float = node.rotation.y
node.set_facing(Vector2(1, 0)) # east / right
var rot_after: float = node.rotation.y
# Rotation must change when facing changes (exact value is implementation-defined)
# We only check that it differs — not the exact angle
# (Different implementations may use 4-dir or 8-dir mappings)
# Guard: allow no-op only if facing east == default (unlikely)
if rot_before == rot_after:
# Try a definitely-different facing
node.set_facing(Vector2(-1, 0)) # west
var rot_west: float = node.rotation.y
assert_bool(rot_west != rot_before).override_failure_message(
"set_facing must rotate the node — east and west must produce different rotations"
).is_true()
func test_set_facing_opposite_directions_differ() -> void:
var node := _make_compositor()
if node == null:
return
# set_facing only writes rotation.y — no asset dependency
node.set_facing(Vector2(0, -1)) # north
var rot_north: float = node.rotation.y
node.set_facing(Vector2(0, 1)) # south
var rot_south: float = node.rotation.y
assert_bool(rot_north != rot_south).override_failure_message(
"North and south facing must produce different rotation values"
).is_true()
# =============================================================================
# 6. Edge cases
# =============================================================================
func test_empty_hair_id_adds_no_hair_node() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub (awaiting #704)")
return
var desc := _make_minimal_descriptor()
desc.hair_id = "" # explicit empty = no hair
node.load_descriptor(desc)
assert_bool(node.has_slot_node("hair")).override_failure_message(
"Empty hair_id must not add a hair node to the character tree"
).is_false()
func test_bald_hair_id_adds_no_hair_node() -> void:
# "bald" is the explicit key for no-hair — treated identically to empty string.
# Stig: `if desc.hair_id.is_empty() or desc.hair_id == "bald": return`
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
return
var desc := _make_minimal_descriptor()
desc.hair_id = "bald"
node.load_descriptor(desc)
assert_bool(node.has_slot_node("hair")).override_failure_message(
"hair_id='bald' must not add a hair node (bald is the explicit no-hair sentinel)"
).is_false()
func test_empty_facial_hair_id_adds_no_facial_hair_node() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("has_slot_node"):
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
return
var desc := _make_minimal_descriptor()
desc.facial_hair_id = ""
node.load_descriptor(desc)
assert_bool(node.has_slot_node("facial_hair")).override_failure_message(
"Empty facial_hair_id must not add a facial hair node"
).is_false()
func test_no_clothing_slots_leaves_no_clothing_nodes() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_clothing_node_count"):
push_warning("TestCharacterVisualSprint28: get_clothing_node_count not found — stub")
return
var desc := _make_minimal_descriptor()
desc.clothing_slots = {}
node.load_descriptor(desc)
assert_int(node.get_clothing_node_count()).override_failure_message(
"No clothing slots in descriptor must produce zero clothing nodes in the scene tree"
).is_equal(0)
func test_no_accessories_adds_no_accessory_nodes() -> void:
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_accessory_node_count"):
push_warning("TestCharacterVisualSprint28: get_accessory_node_count not found — stub")
return
var desc := _make_minimal_descriptor()
desc.accessory_slots = {}
node.load_descriptor(desc)
assert_int(node.get_accessory_node_count()).override_failure_message(
"No accessory slots in descriptor must produce zero accessory nodes"
).is_equal(0)
func test_missing_asset_file_does_not_crash() -> void:
# If an asset file referenced by the descriptor is missing from disk,
# the compositor must degrade gracefully (skip the missing slot, not crash).
var node := _make_compositor()
if node == null:
return
var desc := _make_minimal_descriptor()
desc.hair_id = "hair_does_not_exist_zzz" # guaranteed non-existent
# Must not crash — should either skip the slot or substitute a placeholder
node.load_descriptor(desc)
assert_bool(true).is_true() # survived = pass
func test_all_11_body_types_load_without_crash() -> void:
# Each of the 11 body types must not cause load_descriptor to crash.
# Assets may be missing — but the compositor must handle it gracefully.
var node := _make_compositor()
if node == null:
return
var bt := CharacterVisualDescriptor.BodyType
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
var desc := _make_minimal_descriptor()
desc.body_type = variant
node.load_descriptor(desc)
# If we get here without a crash, all 11 body types were handled
assert_bool(true).is_true()
# =============================================================================
# 7. Skin tone application
# =============================================================================
func test_skin_tone_index_resolves_to_correct_texture_name() -> void:
# The 9 skin tone textures are indexed 08 in this order (per architecture doc):
# 0:pale_cool, 1:pale_warm, 2:light_olive, 3:medium_golden,
# 4:olive_warm, 5:medium_brown, 6:deep_brown, 7:very_deep_warm, 8:very_deep_cool
var node := _make_compositor()
if node == null:
return
if not node.has_method("get_skin_tone_texture_name"):
push_warning("TestCharacterVisualSprint28: get_skin_tone_texture_name not found — stub (awaiting #704)")
return
# Spot-check a few indices
assert_str(node.get_skin_tone_texture_name(0)).override_failure_message(
"Skin tone index 0 must resolve to pale_cool.png"
).is_equal("pale_cool")
assert_str(node.get_skin_tone_texture_name(8)).override_failure_message(
"Skin tone index 8 must resolve to very_deep_cool.png"
).is_equal("very_deep_cool")
func test_skin_tone_textures_exist_on_disk() -> void:
# Verify all 9 expected skin tone PNGs are present (copied from spike in #702)
var expected := [
"pale_cool.png", "pale_warm.png", "light_olive.png", "medium_golden.png",
"olive_warm.png", "medium_brown.png", "deep_brown.png",
"very_deep_warm.png", "very_deep_cool.png",
]
for filename: String in expected:
var path := SKIN_TONES_DIR + filename
assert_bool(ResourceLoader.exists(path)).override_failure_message(
"Skin tone texture missing: %s (should have been copied in #702)" % path
).is_true()
# =============================================================================
# 8. Path construction invariants (pure logic, no assets required)
# =============================================================================
func test_body_type_key_produces_expected_path_fragment() -> void:
# Confirm that CharacterVisualDescriptor.body_type_key() returns the right
# string for use in path construction (this is the bridge between descriptor and compositor)
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
assert_str(d.body_type_key()).is_equal("muscular_f")
func test_all_body_type_keys_match_expected_directory_names() -> void:
# The compositor derives paths as bodies/{body_type_key}/seg_{segment}.glb
# Every key must be lowercase and match the expected directory name convention
var expected := {
CharacterVisualDescriptor.BodyType.THIN_M: "thin_m",
CharacterVisualDescriptor.BodyType.THIN_F: "thin_f",
CharacterVisualDescriptor.BodyType.AVERAGE_M: "average_m",
CharacterVisualDescriptor.BodyType.AVERAGE_F: "average_f",
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "muscular_m",
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "muscular_f",
CharacterVisualDescriptor.BodyType.TEEN_M: "teen_m",
CharacterVisualDescriptor.BodyType.TEEN_F: "teen_f",
CharacterVisualDescriptor.BodyType.HEAVY_M: "heavy_m",
CharacterVisualDescriptor.BodyType.HEAVY_F: "heavy_f",
CharacterVisualDescriptor.BodyType.CHILD: "child",
}
var d := CharacterVisualDescriptor.new()
for body_type: int in expected:
d.body_type = body_type
assert_str(d.body_type_key()).override_failure_message(
"BodyType %d must produce key '%s'" % [body_type, expected[body_type]]
).is_equal(expected[body_type])