323 lines
16 KiB
GDScript
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
|