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>
387 lines
13 KiB
GDScript
387 lines
13 KiB
GDScript
## 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")
|