## 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 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])