Files
stickman/tests/test_phase20a_identity.gd
T

323 lines
16 KiB
GDScript

extends SceneTree
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_scene_identity_and_rename()
_test_current_format_validation()
_test_legacy_migrations()
await _test_adapter_identity_round_trip()
print("PHASE 20A IDENTITY: %d checks, %d failures" % [_checks, _failures])
quit(1 if _failures > 0 else 0)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
push_error("FAIL: " + message)
func _test_scene_identity_and_rename() -> void:
print("\n--- Scene identity and naming ---")
var world := Node2D.new()
var identity: StageObjectIdentity = STAGE_OBJECT_IDENTITY.new() as StageObjectIdentity
var milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
var milo_two: StickmanRig = STICKMAN_RIG.new() as StickmanRig
var prop: PropBlock = PROP_BLOCK.new() as PropBlock
world.add_child(milo)
world.add_child(milo_two)
world.add_child(prop)
identity.assign_new(milo, "Milo", world)
identity.assign_new(milo_two, "Milo", world)
identity.assign_new(prop, "milo", world)
_check(not identity.get_id(milo).is_empty(), "first placed character gets a persistent ID")
_check(identity.get_id(milo_two) != identity.get_id(milo), "duplicate character gets a fresh persistent ID")
_check(identity.get_id(prop) != identity.get_id(milo) and identity.get_id(prop) != identity.get_id(milo_two), "prop ID is unique across both object types")
_check(identity.get_name(milo) == "Milo", "first stage name keeps its familiar base")
_check(identity.get_name(milo_two) == "Milo 2", "duplicate stage name receives the smallest free suffix")
_check(identity.get_name(prop) == "milo 3", "character and prop names share case-insensitive uniqueness")
var capitalization: Dictionary = identity.validate_name(" MILO ", world, milo)
_check(bool(capitalization.get("ok", false)) and String(capitalization.get("name", "")) == "MILO", "capitalization-only rename trims outer spaces and is valid")
var original_id: String = identity.get_id(milo)
var duplicate: Dictionary = identity.rename(milo, "mIlO 2", world)
_check(not bool(duplicate.get("ok", true)) and duplicate.get("reason", &"") == &"duplicate", "duplicate name is rejected case-insensitively")
var suggestion: String = String(duplicate.get("suggestion", ""))
var suggestion_check: Dictionary = identity.validate_name(suggestion, world, milo)
_check(not suggestion.is_empty() and bool(suggestion_check.get("ok", false)), "duplicate name supplies an available suggestion")
var blank: Dictionary = identity.rename(milo, " ", world)
_check(not bool(blank.get("ok", true)) and blank.get("reason", &"") == &"blank", "blank name is rejected")
_check(identity.get_name(milo) == "Milo", "invalid rename leaves the current name unchanged")
var renamed: Dictionary = identity.rename(milo, " MAX ", world)
_check(bool(renamed.get("ok", false)) and identity.get_name(milo) == "MAX", "valid rename stores the trimmed scene name")
_check(identity.get_id(milo) == original_id, "renaming preserves the persistent object ID")
world.remove_child(milo_two)
milo_two.free()
identity.rename(milo, "Milo", world)
var next_milo: StickmanRig = STICKMAN_RIG.new() as StickmanRig
world.add_child(next_milo)
identity.assign_new(next_milo, "Milo", world)
_check(identity.get_name(next_milo) == "Milo 2", "deleting an instance releases its stage name")
world.free()
func _test_current_format_validation() -> void:
print("\n--- Current scene identity validation ---")
var valid: Dictionary = _current_document([
_object("stickman_1", "stickman", "char-1", "Milo"),
_object("prop_1", "prop", "prop-1", "Blue Door"),
])
_check(bool(STAGE_SCENE_IO._validate_root(valid).get("ok", false)), "unique current-format character and prop identities validate")
var duplicate_ids: Dictionary = valid.duplicate(true)
(duplicate_ids["objects"][1] as Dictionary)["stage_object_id"] = "char-1"
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_ids).get("ok", true)), "current-format duplicate IDs across types are rejected")
var empty_id: Dictionary = valid.duplicate(true)
(empty_id["objects"][0] as Dictionary)["stage_object_id"] = " "
_check(not bool(STAGE_SCENE_IO._validate_root(empty_id).get("ok", true)), "current-format empty IDs are rejected")
var duplicate_names: Dictionary = valid.duplicate(true)
(_other_named_object(duplicate_names))["stage_name"] = "mIlO"
_check(not bool(STAGE_SCENE_IO._validate_root(duplicate_names).get("ok", true)), "current-format duplicate names ignoring capitalization are rejected")
var whitespace_name: Dictionary = valid.duplicate(true)
(_empty_names_object(whitespace_name))["stage_name"] = " Milo "
_check(not bool(STAGE_SCENE_IO._validate_root(whitespace_name).get("ok", true)), "current-format names with outer whitespace are rejected")
var missing_name: Dictionary = valid.duplicate(true)
(_empty_names_object(missing_name)).erase("stage_name")
_check(not bool(STAGE_SCENE_IO._validate_root(missing_name).get("ok", true)), "current-format objects missing a name are rejected")
func _test_legacy_migrations() -> void:
print("\n--- Legacy scene migrations ---")
for version: String in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
var source: Dictionary = _legacy_document(version)
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(source)
var migrated_again: Dictionary = STAGE_SCENE_IO._migrate_to_current(source.duplicate(true))
_check(String(migrated.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "%s migrates to current version" % version)
var objects: Array = migrated.get("objects", [])
var repeat_objects: Array = migrated_again.get("objects", [])
_check(objects.size() == 3 and repeat_objects.size() == 3, "%s preserves all named object entries" % version)
var stage_ids: Dictionary = {}
var stage_names: Dictionary = {}
for object_index: int in objects.size():
var entry: Dictionary = objects[object_index] as Dictionary
var repeat_entry: Dictionary = repeat_objects[object_index] as Dictionary
var stage_id: String = String(entry.get("stage_object_id", ""))
var stage_name: String = String(entry.get("stage_name", ""))
_check(not stage_id.is_empty(), "%s object %d receives a persistent ID" % [version, object_index + 1])
_check(not stage_name.strip_edges().is_empty(), "%s object %d receives a nonempty name" % [version, object_index + 1])
_check(not stage_ids.has(stage_id), "%s object %d receives a unique ID" % [version, object_index + 1])
_check(not stage_names.has(stage_name.to_lower()), "%s object %d receives a case-insensitively unique name" % [version, object_index + 1])
stage_ids[stage_id] = true
stage_names[stage_name.to_lower()] = true
_check(stage_id == String(repeat_entry.get("stage_object_id", "")) and stage_name == String(repeat_entry.get("stage_name", "")), "%s migration is deterministic for object %d" % [version, object_index + 1])
var prop_entry: Dictionary = objects[2] as Dictionary
var expected_prop_id: String = "prop_1" if version == "1.0" else "legacy-prop-id"
_check(String(prop_entry.get("stage_object_id", "")) == expected_prop_id, "%s preserves the existing prop ID" % version)
var validation: Dictionary = STAGE_SCENE_IO._validate_root(migrated)
_check(bool(validation.get("ok", false)), "%s migrated document passes current-format validation" % version)
var collision_source: Dictionary = _legacy_document("1.7")
var collision_objects: Array = collision_source["objects"]
(collision_objects[2] as Dictionary)["stage_object_id"] = "stickman:stickman_1"
var collision_migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(collision_source)
var collision_validation: Dictionary = STAGE_SCENE_IO._validate_root(collision_migrated)
_check(String(((collision_migrated["objects"] as Array)[2] as Dictionary).get("stage_object_id", "")) == "stickman:stickman_1", "a saved prop ID is retained when a legacy character ID would collide")
_check(bool(collision_validation.get("ok", false)), "legacy ID collision is resolved without producing invalid current-format data")
func _test_adapter_identity_round_trip() -> void:
print("\n--- Production adapter identity round-trip ---")
var source_world := Node2D.new()
root.add_child(source_world)
var source_spawner: StageSpawner = STAGE_SPAWNER.new(source_world) as StageSpawner
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
var real_character: StickmanRig = source_spawner.spawn_stickman_asset("res://content/stickmen/basic.stk", Vector2(-180.0, 0.0))
var real_prop: PropBlock = source_spawner.spawn_prop_asset("crate", Vector2(180.0, 0.0))
_check(real_character != null and real_prop != null, "production spawner creates real character and prop fixtures")
if real_character == null or real_prop == null:
source_world.queue_free()
await process_frame
return
real_character.stage_object_id = "stage-char-real"
real_character.stage_name = "Milo"
real_prop.stage_object_id = "stage-prop-real"
real_prop.stage_name = "Blue Crate"
var missing_character_entry: Dictionary = {
"file_id": "legacy-missing-character",
"type": "stickman",
"stage_object_id": "stage-char-missing",
"stage_name": "Ava",
"stk_path": "res://content/stickmen/phase20a_missing_character.stk",
"position": {"x": -360.0, "y": 0.0},
"rotation": 0.0,
"action_plan": [],
}
var missing_character: Node2D = MISSING_STICKMAN.new() as Node2D
missing_character.configure(missing_character_entry)
source_world.add_child(missing_character)
var missing_prop_entry: Dictionary = {
"file_id": "legacy-missing-prop",
"type": "prop",
"stage_object_id": "stage-prop-missing",
"stage_name": "Secret Door",
"asset_id": "res://content/props/phase20a_missing_prop.prop",
"position": {"x": 360.0, "y": 0.0},
"rotation": 0.0,
"scale": {"x": 1.0, "y": 1.0},
"use_spots": [],
"next_use_spot_number": 1,
}
var missing_prop: Node2D = MISSING_PROP.new() as Node2D
missing_prop.configure(missing_prop_entry)
source_world.add_child(missing_prop)
await process_frame
var document: Dictionary = adapter.collect(
source_world,
{},
[],
{"position": Vector2(32.0, -64.0), "zoom": 0.75},
{"scene_name": "Identity Round Trip"}
)
var serialized: Dictionary = {}
for entry_value: Variant in document.get("objects", []):
if entry_value is Dictionary:
var entry: Dictionary = entry_value
serialized[String(entry.get("stage_object_id", ""))] = entry
for identity_pair in [
["stage-char-real", "Milo"],
["stage-prop-real", "Blue Crate"],
["stage-char-missing", "Ava"],
["stage-prop-missing", "Secret Door"],
]:
var entry: Dictionary = serialized.get(identity_pair[0], {}) as Dictionary
_check(String(entry.get("stage_name", "")) == identity_pair[1], "collect preserves name '%s' for %s" % [identity_pair[1], identity_pair[0]])
_check(not entry.is_empty(), "collect preserves identity %s" % identity_pair[0])
# write_file_safe uses absolute-path rename operations after writing its temporary file.
var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20a_identity_round_trip.scene")
var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, document)
_check(bool(write_result.get("ok", false)), "scene writer saves the collected identity document: %s" % String(write_result.get("error", "")))
var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path)
_check(bool(read_result.get("ok", false)), "scene reader loads the saved identity document")
var disk_document: Dictionary = read_result.get("data", {}) as Dictionary
var apply_document: Dictionary = disk_document if not disk_document.is_empty() else document
var disk_objects_by_id: Dictionary = {}
for disk_entry_value: Variant in apply_document.get("objects", []):
if disk_entry_value is Dictionary:
var disk_entry: Dictionary = disk_entry_value as Dictionary
disk_objects_by_id[String(disk_entry.get("stage_object_id", ""))] = disk_entry
for identity_pair in [
["stage-char-real", "Milo"],
["stage-prop-real", "Blue Crate"],
["stage-char-missing", "Ava"],
["stage-prop-missing", "Secret Door"],
]:
var disk_entry: Dictionary = disk_objects_by_id.get(identity_pair[0], {}) as Dictionary
_check(String(disk_entry.get("stage_name", "")) == identity_pair[1], "disk round-trip retains name '%s'" % identity_pair[1])
_check(not disk_entry.is_empty(), "disk round-trip retains ID %s" % identity_pair[0])
var target_world := Node2D.new()
root.add_child(target_world)
var target_spawner: StageSpawner = STAGE_SPAWNER.new(target_world) as StageSpawner
var applied: Dictionary = adapter.apply(apply_document, target_world, target_spawner)
var restored_by_id: Dictionary = {}
for node_value: Variant in applied.get("loaded_nodes", []):
var node: Node2D = node_value as Node2D
if node != null:
restored_by_id[String(node.get("stage_object_id"))] = node
for identity_pair in [
["stage-char-real", "Milo"],
["stage-prop-real", "Blue Crate"],
["stage-char-missing", "Ava"],
["stage-prop-missing", "Secret Door"],
]:
var restored: Node2D = restored_by_id.get(identity_pair[0]) as Node2D
_check(restored != null and String(restored.get("stage_name")) == identity_pair[1], "apply restores ID and name for %s" % identity_pair[0])
_check(restored_by_id["stage-char-missing"].get_script() == MISSING_STICKMAN, "missing character asset restores as its editable placeholder")
_check(restored_by_id["stage-prop-missing"].get_script() == MISSING_PROP, "missing custom prop restores as its editable placeholder")
var adapter_warnings: Array = applied.get("warnings", []) as Array
_check(adapter_warnings.is_empty(), "adapter reports no malformed-object warnings during identity round-trip")
target_world.queue_free()
source_world.queue_free()
await process_frame
DirAccess.remove_absolute(ProjectSettings.globalize_path(save_path))
func _empty_names_object(document: Dictionary) -> Dictionary:
return (document["objects"] as Array)[0] as Dictionary
func _other_named_object(document: Dictionary) -> Dictionary:
return (document["objects"] as Array)[1] as Dictionary
func _object(file_id: String, object_type: String, stage_id: String, stage_name: String) -> Dictionary:
return {
"file_id": file_id,
"type": object_type,
"stage_object_id": stage_id,
"stage_name": stage_name,
"position": {"x": 0.0, "y": 0.0},
"rotation": 0.0,
}
func _current_document(objects: Array) -> Dictionary:
for raw_object: Variant in objects:
if raw_object is Dictionary and String((raw_object as Dictionary).get("type", "")) == "stickman":
(raw_object as Dictionary)["action_plan"] = []
(raw_object as Dictionary).erase("action_queue")
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": "Identity Test",
"sky_id": "sky_neutral",
"background_kind": "sky",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5},
"objects": objects,
"event_rules": [],
"metadata": {},
}
func _legacy_document(version: String) -> Dictionary:
var prop: Dictionary = _object("prop_1", "prop", "legacy-prop-id", "Ignored")
prop["asset_id"] = "res://content/props/briefcase.prop"
prop["scale"] = {"x": 1.0, "y": 1.0}
prop["material_preset"] = 0
if version == "1.0":
prop.erase("stage_object_id")
prop.erase("stage_name")
var first_character: Dictionary = _object("stickman_1", "stickman", "", "")
first_character["stk_path"] = "res://content/stickmen/test.stk"
first_character["action_queue"] = []
first_character.erase("stage_object_id")
first_character.erase("stage_name")
var second_character: Dictionary = _object("stickman_2", "stickman", "", "")
second_character["stk_path"] = "res://content/stickmen/test.stk"
second_character["action_queue"] = []
second_character.erase("stage_object_id")
second_character.erase("stage_name")
var document: Dictionary = _current_document([first_character, second_character, prop])
document["version"] = version
return document