Files
settled-reach/client/tests/test_sprint30.gd
T
jpmschweitzerandClaude Opus 4.6 cbcfeccd67 fix(ui): PR #131 review round 1 — retire starchart, harden registry
Addresses blocker comments from PR review:

- Delete star_map.gd and star_map.tscn — dead implant/map/starchart
  HudGroups registration that should have landed with the atlas
  unification (D-191 criterion 1)
- Remove test_star_map_is_accessible_from_insert_ui and
  test_star_map_scene_exists from test_sprint30.gd — D-191 supersedes
  the insert-UI accessibility pattern
- ImplantRegistry._scan() now detects default_key collisions
  (first-wins with push_warning) and validates default_mode against
  HudGroups.Mode enum (skip + warn on invalid)

star_map_data.json remains — still used by atlas_app and
economics_app for system index lookups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 14:03:25 +02:00

553 lines
22 KiB
GDScript

## 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("hair_highlight_tint" in desc).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()