test(client): add Sprint 30 acceptance tests
27 tests covering all 5 sprint tickets: descriptor persistence (#718), overhead anchor (#712), hair highlight swatch (#719), manifest JSON completeness (#720), and star map scene/HUD integration (#674). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
## Sprint 30 — QA acceptance tests
|
||||
##
|
||||
## Covers all 5 Sprint 30 client tickets:
|
||||
## #718 — Persist CharacterVisualDescriptor on new game start
|
||||
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
|
||||
## #720 — Replace DirAccess scanning with manifest JSON for export builds
|
||||
## #712 — BoneAttachment3D marker above Head bone for floating icons
|
||||
## #674 — Star map insert module (test-first: scene must exist when implemented)
|
||||
##
|
||||
## Test convention:
|
||||
## - Tests that should PASS immediately = regression guards on existing code
|
||||
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
|
||||
##
|
||||
## Ticket refs: #718, #719, #720, #712, #674
|
||||
class_name TestSprint30
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #718 — Persist CharacterVisualDescriptor
|
||||
# =============================================================================
|
||||
|
||||
func test_game_state_has_character_visual_descriptor_field() -> void:
|
||||
## GameState.character_visual_descriptor must exist and default to null.
|
||||
## Confirms the field added in game_state.gd line 103 is present.
|
||||
var gs := GameState.new()
|
||||
auto_free(gs)
|
||||
# The field is declared on the class — access it without error
|
||||
var val: Variant = gs.get("character_visual_descriptor")
|
||||
# Field should exist (not return null from missing property vs. null value)
|
||||
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
|
||||
"GameState must be a valid autoload class with apply_snapshot"
|
||||
).is_true()
|
||||
# The property itself must be gettable and null by default
|
||||
assert_bool(val == null).override_failure_message(
|
||||
"GameState.character_visual_descriptor must default to null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_includes_all_required_fields() -> void:
|
||||
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
var required_keys := [
|
||||
"body_type", "head_id", "hair_id", "hair_tint",
|
||||
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
|
||||
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
|
||||
"accessory_slots", "accessory_tints",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert_bool(d.has(key)).override_failure_message(
|
||||
"to_dict() must include field '%s'" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
|
||||
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
var d := desc.to_dict()
|
||||
assert_str(d["body_type"]).override_failure_message(
|
||||
"to_dict() body_type must be wire string 'AverageM'"
|
||||
).is_equal("AverageM")
|
||||
|
||||
|
||||
func test_descriptor_round_trip_preserves_fields() -> void:
|
||||
## from_dict(to_dict(desc)) must preserve all scalar fields.
|
||||
var original := CharacterVisualDescriptor.new()
|
||||
auto_free(original)
|
||||
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
|
||||
original.head_id = "head_002"
|
||||
original.hair_id = "bob"
|
||||
original.hair_tint = Color(0.8, 0.4, 0.2)
|
||||
original.skin_tone = 3
|
||||
|
||||
var wire := original.to_dict()
|
||||
var restored := CharacterVisualDescriptor.from_dict(wire)
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"from_dict() must return a descriptor for valid wire data"
|
||||
).is_true()
|
||||
if restored == null:
|
||||
return
|
||||
|
||||
assert_int(int(restored.body_type)).override_failure_message(
|
||||
"body_type must survive round-trip"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
|
||||
|
||||
assert_str(restored.head_id).override_failure_message(
|
||||
"head_id must survive round-trip"
|
||||
).is_equal("head_002")
|
||||
|
||||
assert_str(restored.hair_id).override_failure_message(
|
||||
"hair_id must survive round-trip"
|
||||
).is_equal("bob")
|
||||
|
||||
assert_int(restored.skin_tone).override_failure_message(
|
||||
"skin_tone must survive round-trip"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
|
||||
## from_dict() must return null if body_type is absent (required field).
|
||||
var d := {"head_id": "head_001"} # missing body_type
|
||||
var result := CharacterVisualDescriptor.from_dict(d)
|
||||
assert_bool(result == null).override_failure_message(
|
||||
"from_dict() must return null when body_type is missing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_color_encoding_is_float_array() -> void:
|
||||
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
|
||||
var d := desc.to_dict()
|
||||
var encoded: Variant = d["eye_color"]
|
||||
assert_bool(encoded is Array).override_failure_message(
|
||||
"eye_color must encode as an Array [r, g, b, a]"
|
||||
).is_true()
|
||||
if not (encoded is Array):
|
||||
return
|
||||
assert_int((encoded as Array).size()).override_failure_message(
|
||||
"eye_color array must have 4 elements"
|
||||
).is_equal(4)
|
||||
assert_float((encoded as Array)[0]).override_failure_message(
|
||||
"eye_color[0] (r) must be approx 0.1"
|
||||
).is_equal_approx(0.1, 0.001)
|
||||
|
||||
|
||||
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
|
||||
## #718: apply_snapshot() must restore character_visual_descriptor from
|
||||
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||||
desc.head_id = "head_003"
|
||||
desc.hair_id = "dreads"
|
||||
desc.skin_tone = 5
|
||||
var snapshot := {
|
||||
"character_visual_descriptor": desc.to_dict(),
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
var restored: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"apply_snapshot() must restore character_visual_descriptor from snapshot"
|
||||
).is_true()
|
||||
if restored != null and restored is CharacterVisualDescriptor:
|
||||
var r := restored as CharacterVisualDescriptor
|
||||
assert_int(int(r.body_type)).override_failure_message(
|
||||
"restored body_type must match"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
|
||||
assert_str(r.head_id).override_failure_message(
|
||||
"restored head_id must match"
|
||||
).is_equal("head_003")
|
||||
assert_int(r.skin_tone).override_failure_message(
|
||||
"restored skin_tone must match"
|
||||
).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
|
||||
## #718: If snapshot lacks "character_visual_descriptor", the existing field
|
||||
## must not be overwritten (server only sends when descriptor changes).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.hair_id = "bob"
|
||||
GameState.character_visual_descriptor = desc
|
||||
# Snapshot with no character_visual_descriptor key
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
var after: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(after != null).override_failure_message(
|
||||
"apply_snapshot() must NOT clear descriptor when field is absent"
|
||||
).is_true()
|
||||
if after is CharacterVisualDescriptor:
|
||||
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
|
||||
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
|
||||
).is_equal("bob")
|
||||
|
||||
|
||||
func test_protocol_encode_startup_includes_descriptor() -> void:
|
||||
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
|
||||
## in the encoded payload when a descriptor is provided.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
|
||||
desc.hair_id = "buzzed"
|
||||
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce non-empty bytes"
|
||||
).is_true()
|
||||
# Decode and verify the field is present (Messagepack.decode returns {status, value})
|
||||
var raw = Messagepack.decode(bytes)
|
||||
assert_bool(raw.status == null).override_failure_message(
|
||||
"encode_startup_message() output must be valid msgpack"
|
||||
).is_true()
|
||||
if raw.status != null:
|
||||
return
|
||||
var msg: Dictionary = raw.value as Dictionary
|
||||
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
|
||||
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
|
||||
).is_true()
|
||||
if msg.has("character_visual_descriptor"):
|
||||
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
|
||||
"character_visual_descriptor in StartupMessage must be a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
|
||||
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
|
||||
var bytes := Protocol.encode_startup_message(0, "detective", null)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce valid bytes even with null descriptor"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
|
||||
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
|
||||
## The highlight is always auto-derived from hair_tint (Option B of #719).
|
||||
## to_dict() must not include it in the wire format.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
|
||||
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
|
||||
).is_false()
|
||||
assert_bool(desc.get("hair_highlight_tint") != null and typeof(desc.get("hair_highlight_tint")) != TYPE_NIL).override_failure_message(
|
||||
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
|
||||
# =============================================================================
|
||||
|
||||
func test_derive_hair_highlight_lightens_primary() -> void:
|
||||
## _derive_hair_highlight() must return primary.lightened(0.3).
|
||||
## Tests the derivation formula in character_creation.gd:1917.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
|
||||
var primary := Color(0.4, 0.3, 0.5)
|
||||
var expected := primary.lightened(0.3)
|
||||
var result: Variant = cc.call("_derive_hair_highlight", primary)
|
||||
assert_bool(result is Color).override_failure_message(
|
||||
"_derive_hair_highlight must return a Color"
|
||||
).is_true()
|
||||
if not (result is Color):
|
||||
return
|
||||
var r := result as Color
|
||||
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
|
||||
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
|
||||
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_exists_in_ui() -> void:
|
||||
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
|
||||
## (a display node must be created in _build_hair_color_dock).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
assert_bool(swatch != null).override_failure_message(
|
||||
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_is_not_interactive() -> void:
|
||||
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
|
||||
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
if swatch == null:
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
|
||||
return
|
||||
|
||||
# If swatch is a Control node, mouse_filter must be IGNORE (2)
|
||||
if swatch is Control:
|
||||
var ctrl := swatch as Control
|
||||
assert_int(ctrl.mouse_filter).override_failure_message(
|
||||
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
|
||||
# =============================================================================
|
||||
|
||||
func test_manifest_json_is_parseable() -> void:
|
||||
## manifest.json must exist and parse as a Dictionary.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
|
||||
"manifest.json must exist at res://assets/characters/manifest.json"
|
||||
).is_true()
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
assert_bool(parsed is Dictionary).override_failure_message(
|
||||
"manifest.json must parse as a JSON object (Dictionary)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_hair_includes_all_asset_dirs() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
|
||||
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
|
||||
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
|
||||
## WILL FAIL until #720 populates the manifest fully.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var hair_list: Array = manifest.get("hair", [])
|
||||
|
||||
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
|
||||
var expected_hair := [
|
||||
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
|
||||
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
|
||||
"simple_parted", "slick_back",
|
||||
]
|
||||
for hair_id in expected_hair:
|
||||
assert_bool(hair_list.has(hair_id)).override_failure_message(
|
||||
"[#720] manifest 'hair' must include '%s'" % hair_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_heads_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
|
||||
## heads/templates/ contains head_001..head_004 — all must be listed.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var heads_list: Array = manifest.get("heads", [])
|
||||
|
||||
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
|
||||
assert_bool(not heads_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
|
||||
).is_true()
|
||||
for head_id in expected_heads:
|
||||
assert_bool(heads_list.has(head_id)).override_failure_message(
|
||||
"[#720] manifest 'heads' must include '%s'" % head_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_body_types_includes_all_11() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
|
||||
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
|
||||
## WILL FAIL until #720 updates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var bt_list: Array = manifest.get("body_types", [])
|
||||
|
||||
var expected_types := [
|
||||
"thin_m", "thin_f", "average_m", "average_f",
|
||||
"muscular_m", "muscular_f", "teen_m", "teen_f",
|
||||
"heavy_m", "heavy_f", "child",
|
||||
]
|
||||
for bt in expected_types:
|
||||
assert_bool(bt_list.has(bt)).override_failure_message(
|
||||
"[#720] manifest 'body_types' must include '%s'" % bt
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_clothing_includes_all_items() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
|
||||
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
|
||||
## jacket_utility, pants_cargo, shirt_henley.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var clothing_data: Variant = manifest.get("clothing", {})
|
||||
var clothing_keys: Array = []
|
||||
if clothing_data is Dictionary:
|
||||
clothing_keys = (clothing_data as Dictionary).keys()
|
||||
|
||||
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
|
||||
var expected_items := [
|
||||
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
|
||||
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
|
||||
]
|
||||
for item_id in expected_items:
|
||||
assert_bool(clothing_keys.has(item_id)).override_failure_message(
|
||||
"[#720] manifest 'clothing' must include '%s'" % item_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_eyebrows_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
|
||||
## assets/characters/eyebrows/ has: female, regular, teen, thick.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var eb_list: Array = manifest.get("eyebrows", [])
|
||||
|
||||
var expected_eyebrows := ["female", "regular", "teen", "thick"]
|
||||
assert_bool(not eb_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must not be empty"
|
||||
).is_true()
|
||||
for eb_id in expected_eyebrows:
|
||||
assert_bool(eb_list.has(eb_id)).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_dir_access_scan_functions_removed() -> void:
|
||||
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
|
||||
## removed from CharacterCreation. These functions fail in exported PCK builds.
|
||||
## WILL FAIL until #720 removes the DirAccess fallbacks.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
|
||||
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
|
||||
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
|
||||
# =============================================================================
|
||||
|
||||
func test_character_visual_has_get_overhead_anchor() -> void:
|
||||
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
|
||||
## This is a static assertion — no 3D assets required.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
|
||||
"CharacterVisual must have get_overhead_anchor() method (#712)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_is_null_before_load() -> void:
|
||||
## get_overhead_anchor() must return null before load_descriptor() is called.
|
||||
## The anchor is created during skeleton load, not at construction.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
var anchor: Variant = cv.get_overhead_anchor()
|
||||
assert_bool(anchor == null).override_failure_message(
|
||||
"get_overhead_anchor() must be null before load_descriptor() is called"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_offset_constant() -> void:
|
||||
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
|
||||
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
|
||||
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
|
||||
## This test loads a scene and verifies if assets are present.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
add_child(cv)
|
||||
|
||||
# Without GLB assets available in headless, skeleton load is a no-op.
|
||||
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
|
||||
var attachment: Variant = cv.get("_overhead_attachment")
|
||||
assert_bool(attachment == null).override_failure_message(
|
||||
"_overhead_attachment must be null before skeleton is loaded"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #674 — Star map insert module (test-first)
|
||||
# =============================================================================
|
||||
|
||||
func test_star_map_scene_exists() -> void:
|
||||
## [ACCEPTANCE #674] The star map scene must exist at the expected path.
|
||||
## WILL FAIL until #674 is implemented.
|
||||
var expected_path := "res://ui/star_map.tscn"
|
||||
assert_bool(ResourceLoader.exists(expected_path)).override_failure_message(
|
||||
"[#674] Star map scene must exist at res://ui/star_map.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_star_map_is_accessible_from_insert_ui() -> void:
|
||||
## [ACCEPTANCE #674] The star map module must be reachable from the insert UI.
|
||||
## Verify via HUD or main scene that a star_map node/scene is connected.
|
||||
## WILL FAIL until #674 wires the scene into the insert layer.
|
||||
var hud_scene_path := "res://ui/hud.tscn"
|
||||
if not ResourceLoader.exists(hud_scene_path):
|
||||
push_warning("test_star_map_is_accessible_from_insert_ui: HUD scene not found — skipping")
|
||||
return
|
||||
var packed := load(hud_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var hud := packed.instantiate()
|
||||
if hud == null:
|
||||
return
|
||||
auto_free(hud)
|
||||
add_child(hud)
|
||||
await get_tree().process_frame
|
||||
|
||||
# Star map must be reachable as a named node from the HUD or insert layer
|
||||
var star_map := hud.get_node_or_null("StarMap")
|
||||
assert_bool(star_map != null).override_failure_message(
|
||||
"[#674] HUD must contain a StarMap node accessible from the insert UI"
|
||||
).is_true()
|
||||
Reference in New Issue
Block a user