feat(client): CharacterVisualDescriptor and compositor (#703, #704)

Add CharacterVisualDescriptor (11-variant BodyType enum, wire format
encode/decode, body_type_key mapping) and CharacterVisual compositor
(runtime 3D character assembler with slot architecture, BoneAttachment3D,
clothing coverage, recolor mask loading, skin tone tinting, bone
validation). Includes 64 unit tests and Blender utility scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 07:48:47 +01:00
co-authored by Claude Opus 4.6
parent ff3a8e95e4
commit 23ec1ddebd
8 changed files with 1951 additions and 0 deletions
@@ -0,0 +1,386 @@
## Sprint 28 — CharacterVisualDescriptor unit tests (#703)
##
## Validates the GDScript data contract for character visual state.
## Tests cover: BodyType enum (D-159), default fields, field mutation,
## body_type_key() helper, from_dict() / to_dict() round-trips, and
## edge cases (missing fields, unknown body_type wire string).
##
## The Rust side (server/src/bridge/types.rs) is out of scope for this
## client-team file — Rust tests live server-side.
##
## Spec: D-159 (11 body types), D-160 (21 segments), D-161 (head separate)
## Ticket: #703
class_name TestCharacterVisualDescriptorSprint28
extends GdUnitTestSuite
# -- BodyType enum — 11 variants (D-159) --------------------------------------
func test_body_type_enum_has_11_variants() -> void:
# D-159: 4 adult types × 2 genders + child = 11 total.
var bt := CharacterVisualDescriptor.BodyType
var variants := [
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,
]
assert_int(variants.size()).override_failure_message(
"BodyType enum must have exactly 11 variants per D-159"
).is_equal(11)
func test_body_type_all_variants_exist() -> void:
# Each variant must be accessible without raising an error
var bt := CharacterVisualDescriptor.BodyType
assert_bool(bt.THIN_M >= 0).is_true()
assert_bool(bt.THIN_F >= 0).is_true()
assert_bool(bt.AVERAGE_M >= 0).is_true()
assert_bool(bt.AVERAGE_F >= 0).is_true()
assert_bool(bt.MUSCULAR_M >= 0).is_true()
assert_bool(bt.MUSCULAR_F >= 0).is_true()
assert_bool(bt.TEEN_M >= 0).is_true()
assert_bool(bt.TEEN_F >= 0).is_true()
assert_bool(bt.HEAVY_M >= 0).is_true()
assert_bool(bt.HEAVY_F >= 0).is_true()
assert_bool(bt.CHILD >= 0).is_true()
# -- BODY_TYPE_KEYS lookup coverage -------------------------------------------
func test_body_type_keys_covers_all_variants() -> void:
# BODY_TYPE_KEYS must have an entry for every BodyType variant
var bt := CharacterVisualDescriptor.BodyType
var keys := CharacterVisualDescriptor.BODY_TYPE_KEYS
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]:
assert_bool(keys.has(variant)).override_failure_message(
"BODY_TYPE_KEYS missing variant %d" % variant
).is_true()
func test_body_type_key_average_m_is_average_m() -> void:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
assert_str(d.body_type_key()).is_equal("average_m")
func test_body_type_key_child_is_child() -> void:
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.CHILD
assert_str(d.body_type_key()).is_equal("child")
func test_body_type_keys_all_lowercase_snake_case() -> void:
# File keys must match filesystem snake_case convention
for key: String in CharacterVisualDescriptor.BODY_TYPE_KEYS.values():
assert_str(key).is_equal(key.to_lower())
# -- Wire format dictionaries -------------------------------------------------
func test_body_type_from_wire_covers_all_11_variants() -> void:
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
assert_int(from_wire.size()).override_failure_message(
"BODY_TYPE_FROM_WIRE must have 11 entries (one per BodyType variant)"
).is_equal(11)
func test_body_type_to_wire_covers_all_11_variants() -> void:
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
assert_int(to_wire.size()).override_failure_message(
"BODY_TYPE_TO_WIRE must have 11 entries"
).is_equal(11)
func test_wire_round_trip_all_body_types() -> void:
# from_wire → to_wire must be the identity for every variant
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
for wire_str: String in from_wire:
var bt = from_wire[wire_str]
assert_str(to_wire[bt]).override_failure_message(
"to_wire(from_wire('%s')) should equal '%s'" % [wire_str, wire_str]
).is_equal(wire_str)
# -- Default field values -----------------------------------------------------
func test_construction_does_not_crash() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d != null).is_true()
func test_default_body_type_is_average_m() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_M)
func test_default_head_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.head_id).is_equal("")
func test_default_hair_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.hair_id).is_equal("")
func test_default_hair_tint_is_white() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d.hair_tint == Color.WHITE).override_failure_message(
"Default hair_tint should be Color.WHITE"
).is_true()
func test_default_facial_hair_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.facial_hair_id).is_equal("")
func test_default_eyebrow_id_is_empty_string() -> void:
var d := CharacterVisualDescriptor.new()
assert_str(d.eyebrow_id).is_equal("")
func test_default_eyebrow_tint_is_white() -> void:
var d := CharacterVisualDescriptor.new()
assert_bool(d.eyebrow_tint == Color.WHITE).is_true()
func test_default_skin_tone_is_zero() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.skin_tone).is_equal(0)
func test_default_dicts_are_empty() -> void:
var d := CharacterVisualDescriptor.new()
assert_int(d.clothing_slots.size()).is_equal(0)
assert_int(d.clothing_tints.size()).is_equal(0)
assert_int(d.accessory_slots.size()).is_equal(0)
assert_int(d.accessory_tints.size()).is_equal(0)
# -- Mutation round-trips -----------------------------------------------------
func test_body_type_can_be_set_to_all_variants() -> void:
var d := CharacterVisualDescriptor.new()
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]:
d.body_type = variant
assert_int(d.body_type).is_equal(variant)
func test_head_id_round_trips() -> void:
var d := CharacterVisualDescriptor.new()
d.head_id = "head_042"
assert_str(d.head_id).is_equal("head_042")
func test_hair_id_round_trips() -> void:
var d := CharacterVisualDescriptor.new()
d.hair_id = "ponytail"
assert_str(d.hair_id).is_equal("ponytail")
func test_skin_tone_accepts_full_range_0_to_8() -> void:
# 9-tone palette: valid indices 0–8
var d := CharacterVisualDescriptor.new()
for i in range(9):
d.skin_tone = i
assert_int(d.skin_tone).is_equal(i)
func test_clothing_slots_can_be_populated() -> void:
var d := CharacterVisualDescriptor.new()
d.clothing_slots["torso"] = "coveralls_basic"
d.clothing_slots["legs"] = "trousers_01"
assert_int(d.clothing_slots.size()).is_equal(2)
assert_str(d.clothing_slots["torso"]).is_equal("coveralls_basic")
func test_accessory_slots_can_be_populated() -> void:
var d := CharacterVisualDescriptor.new()
d.accessory_slots["hat"] = "hat_hardhat"
assert_str(d.accessory_slots["hat"]).is_equal("hat_hardhat")
# -- from_dict() / to_dict() round-trips --------------------------------------
func test_from_dict_minimal_valid_input() -> void:
# Only required fields; optional fields fall back to defaults
var data := {
"body_type": "AverageF",
"head_id": "head_007",
}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d != null).override_failure_message(
"from_dict must succeed with required fields present"
).is_true()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
assert_str(d.head_id).is_equal("head_007")
func test_from_dict_all_fields() -> void:
var data := {
"body_type": "MuscularM",
"head_id": "head_003",
"hair_id": "buzz",
"hair_tint": [0.2, 0.15, 0.1, 1.0],
"facial_hair_id": "stubble",
"facial_hair_tint": [0.1, 0.08, 0.06, 1.0],
"eyebrow_id": "thick",
"eyebrow_tint": [0.1, 0.08, 0.06, 1.0],
"skin_tone": 4,
"clothing_slots": {"torso": "shirt_02"},
"clothing_tints": {},
"accessory_slots": {},
"accessory_tints": {},
}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d != null).is_true()
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.MUSCULAR_M)
assert_str(d.hair_id).is_equal("buzz")
assert_str(d.facial_hair_id).is_equal("stubble")
assert_int(d.skin_tone).is_equal(4)
assert_str(d.clothing_slots.get("torso", "")).is_equal("shirt_02")
func test_from_dict_missing_body_type_returns_null() -> void:
# Required field missing — must return null (not crash)
var data := {"head_id": "head_001"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null when body_type is absent"
).is_true()
func test_from_dict_missing_head_id_returns_null() -> void:
var data := {"body_type": "AverageM"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null when head_id is absent"
).is_true()
func test_from_dict_unknown_body_type_returns_null() -> void:
# Unknown wire string — must return null, not crash or silently corrupt
var data := {"body_type": "INVALID_TYPE", "head_id": "head_001"}
var d := CharacterVisualDescriptor.from_dict(data)
assert_bool(d == null).override_failure_message(
"from_dict must return null for unknown body_type wire string"
).is_true()
func test_to_dict_produces_string_body_type() -> void:
# Wire format: body_type must be a string, not an integer
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.THIN_F
var wire := d.to_dict()
assert_bool(wire["body_type"] is String).override_failure_message(
"to_dict body_type must be a wire string, not an enum int"
).is_true()
assert_str(wire["body_type"]).is_equal("ThinF")
func test_full_round_trip_to_dict_from_dict() -> void:
# Build a descriptor, encode, decode — all fields must survive
var orig := CharacterVisualDescriptor.new()
orig.body_type = CharacterVisualDescriptor.BodyType.HEAVY_F
orig.head_id = "head_099"
orig.hair_id = "long"
orig.hair_tint = Color(0.5, 0.3, 0.1, 1.0)
orig.facial_hair_id = ""
orig.eyebrow_id = "thin"
orig.skin_tone = 7
orig.clothing_slots = {"torso": "jacket_01"}
var wire := orig.to_dict()
var restored := CharacterVisualDescriptor.from_dict(wire)
assert_bool(restored != null).is_true()
assert_int(restored.body_type).is_equal(CharacterVisualDescriptor.BodyType.HEAVY_F)
assert_str(restored.head_id).is_equal("head_099")
assert_str(restored.hair_id).is_equal("long")
assert_int(restored.skin_tone).is_equal(7)
assert_str(restored.clothing_slots.get("torso", "")).is_equal("jacket_01")
func test_to_dict_hair_tint_encodes_as_float_array() -> void:
# Color must encode as [r, g, b, a] float array for rmp_serde
var d := CharacterVisualDescriptor.new()
d.hair_tint = Color(0.8, 0.3, 0.1, 1.0)
var wire := d.to_dict()
var tint = wire["hair_tint"]
assert_bool(tint is Array).override_failure_message(
"hair_tint in wire must be Array[float], not Color"
).is_true()
assert_int((tint as Array).size()).is_equal(4)
# -- Edge cases ---------------------------------------------------------------
func test_separate_instances_have_independent_dicts() -> void:
# Dictionary defaults must not be shared between instances (aliasing bug)
var a := CharacterVisualDescriptor.new()
var b := CharacterVisualDescriptor.new()
a.clothing_slots["torso"] = "shirt_a"
assert_bool("torso" in b.clothing_slots).override_failure_message(
"clothing_slots must be independent across instances — not shared"
).is_false()
func test_skin_tone_boundary_min_zero() -> void:
var d := CharacterVisualDescriptor.new()
d.skin_tone = 0
assert_int(d.skin_tone).is_equal(0)
func test_skin_tone_boundary_max_eight() -> void:
# 9-tone palette: index 8 is the maximum (very_deep_cool or very_deep_warm)
var d := CharacterVisualDescriptor.new()
d.skin_tone = 8
assert_int(d.skin_tone).is_equal(8)
func test_facial_hair_empty_string_means_none() -> void:
# Spec: "empty string = none" for facial_hair_id
var d := CharacterVisualDescriptor.new()
d.facial_hair_id = ""
assert_str(d.facial_hair_id).is_equal("")
func test_hardcoded_descriptor_matches_ticket_acceptance_criterion() -> void:
# Ticket #703: "A hardcoded test descriptor can be constructed in GDScript
# and matched to a Rust struct without panics."
# This validates the GDScript half. Rust half lives in server-team tests.
var d := CharacterVisualDescriptor.new()
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
d.head_id = "head_001"
d.hair_id = "bob"
d.hair_tint = Color(0.4, 0.25, 0.1, 1.0)
d.facial_hair_id = ""
d.eyebrow_id = "thin"
d.eyebrow_tint = Color(0.3, 0.2, 0.1, 1.0)
d.skin_tone = 2
d.clothing_slots = {"torso": "coveralls_basic", "legs": "trousers_basic"}
d.clothing_tints = {"coveralls_basic": [Color.DARK_SLATE_GRAY]}
d.accessory_slots = {}
d.accessory_tints = {}
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
assert_str(d.head_id).is_equal("head_001")
assert_str(d.hair_id).is_equal("bob")
assert_int(d.skin_tone).is_equal(2)
assert_int(d.clothing_slots.size()).is_equal(2)
assert_int(d.accessory_slots.size()).is_equal(0)
# And it must encode to a valid wire dict without crashing
var wire := d.to_dict()
assert_bool(wire.has("body_type")).is_true()
assert_str(wire["body_type"]).is_equal("AverageF")
@@ -0,0 +1,553 @@
## 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 (15+2+2 segments), 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 not double the children (would indicate no cleanup)
assert_bool(count_after_second <= count_after_first * 2).override_failure_message(
"load_descriptor must clear previous node tree before building new one — children doubled, suggesting stale nodes were kept"
).is_true()
# =============================================================================
# 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 0–8 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])