242 lines
13 KiB
GDScript
242 lines
13 KiB
GDScript
extends SceneTree
|
|
|
|
const IO := preload("res://scripts/stage/stage_scene_io.gd")
|
|
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
|
|
const MISSING_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
_test_vectors_and_queue()
|
|
_test_rules()
|
|
_test_safe_write()
|
|
_test_root_validation()
|
|
_test_wildcards_and_unresolved_rules()
|
|
await _test_stage_round_trip_and_file_state()
|
|
print("----------------------------------------")
|
|
if _failures == 0:
|
|
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
|
quit(0)
|
|
else:
|
|
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
|
quit(1)
|
|
|
|
|
|
func _expect(condition: bool, message: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print("PASS: " + message)
|
|
else:
|
|
_failures += 1
|
|
push_error("Phase 5 test failed: " + message)
|
|
|
|
|
|
func _test_vectors_and_queue() -> void:
|
|
var source := Vector2(12.5, -3.0)
|
|
var encoded := IO.vector_to_data(source)
|
|
_expect(IO.is_vector_data(encoded), "vector is JSON-safe")
|
|
_expect(IO.data_to_vector(encoded, Vector2.ZERO) == source, "vector round trip")
|
|
_expect(not IO.is_vector_data({"x": INF, "y": 0.0}), "non-finite vector is rejected")
|
|
var queue: Array[Dictionary] = [
|
|
{"type": "walk_to", "target": source},
|
|
{"type": "speak", "text": "Hi", "duration": 1.0},
|
|
{"type": "wait", "duration": 0.5},
|
|
{"type": "ragdoll"},
|
|
{"type": "recover"},
|
|
]
|
|
var decoded: Dictionary = IO.decode_queue(IO.encode_queue(queue)["actions"])
|
|
_expect((decoded["actions"] as Array).size() == queue.size(), "all queue action types round trip")
|
|
var bad_queue: Array[Dictionary] = [{"type": "wait", "duration": INF}, {"type": "unknown"}]
|
|
_expect((IO.encode_queue(bad_queue)["actions"] as Array).is_empty(), "invalid queue actions are skipped")
|
|
|
|
|
|
func _test_rules() -> void:
|
|
var rules: Array[Dictionary] = [{
|
|
"id": 2,
|
|
"trigger": {
|
|
"type": "arrived_at_waypoint",
|
|
"source": 10,
|
|
"target": -1,
|
|
"params": {"waypoint_pos": Vector2(1.0, 2.0)},
|
|
},
|
|
"actions": [{
|
|
"type": "speak",
|
|
"target": 11,
|
|
"params": {"text": "Hello", "duration": 2.0},
|
|
}],
|
|
}]
|
|
var encoded: Dictionary = IO.encode_rules(rules, {10: "stickman_1", 11: "stickman_2"})
|
|
var disk_trigger: Dictionary = (encoded["rules"] as Array)[0]["trigger"]
|
|
_expect(disk_trigger["source_id"] == "stickman_1" and disk_trigger["target_id"] == null, "rule references encode as file IDs and null wildcard")
|
|
var decoded: Dictionary = IO.decode_rules(encoded["rules"], {"stickman_1": 20, "stickman_2": 21}, {})
|
|
var live_rule: Dictionary = (decoded["rules"] as Array)[0]
|
|
_expect(int((live_rule["trigger"] as Dictionary)["source"]) == 20, "rule reference decodes to new runtime ID")
|
|
_expect(int(((live_rule["actions"] as Array)[0] as Dictionary)["target"]) == 21, "rule action target decodes to new runtime ID")
|
|
|
|
|
|
func _test_safe_write() -> void:
|
|
var path := "user://phase5_file_features.scene"
|
|
var first := {"version": "1.0", "scene_name": "First", "objects": [], "event_rules": []}
|
|
var replacement := {"version": "1.0", "scene_name": "Replacement", "objects": [], "event_rules": []}
|
|
_expect(bool(IO.write_file_safe(path, first)["ok"]), "safe initial write")
|
|
_expect(bool(IO.write_file_safe(path, replacement)["ok"]), "safe replacement write")
|
|
var read: Dictionary = IO.read_file(path)
|
|
_expect(bool(read["ok"]), "safe read")
|
|
_expect(String((read["data"] as Dictionary)["scene_name"]) == "Replacement", "replacement content wins")
|
|
DirAccess.remove_absolute(path)
|
|
|
|
|
|
func _test_root_validation() -> void:
|
|
var path := "user://phase5_invalid.scene"
|
|
var duplicate := {
|
|
"version": "1.0",
|
|
"objects": [
|
|
{"file_id": "x", "type": "terrain", "position": {"x": 0.0, "y": 0.0}},
|
|
{"file_id": "x", "type": "prop", "position": {"x": 0.0, "y": 0.0}},
|
|
],
|
|
"event_rules": [],
|
|
}
|
|
_expect(bool(IO.write_file_safe(path, duplicate)["ok"]), "write duplicate-ID fixture")
|
|
_expect(not bool(IO.read_file(path)["ok"]), "duplicate IDs reject before apply")
|
|
var malformed_object := {
|
|
"version": "1.0",
|
|
"objects": [{"file_id": "a", "type": "terrain", "position": {"x": "bad", "y": 0.0}}],
|
|
"event_rules": [],
|
|
}
|
|
_expect(bool(IO.write_file_safe(path, malformed_object)["ok"]), "write malformed-object fixture")
|
|
_expect(bool(IO.read_file(path)["ok"]), "malformed individual object is deferred for warn-and-skip")
|
|
DirAccess.remove_absolute(path)
|
|
|
|
|
|
func _test_wildcards_and_unresolved_rules() -> void:
|
|
var rules: Array[Dictionary] = [
|
|
{"id": 1, "trigger": {"type": "speech_finished", "source": -1, "target": -1, "params": {}}, "actions": [{"type": "recover", "target": 10, "params": {}}]},
|
|
{"id": 1, "trigger": {"type": "speech_finished", "source": -1, "target": -1, "params": {}}, "actions": [{"type": "recover", "target": 10, "params": {}}]},
|
|
]
|
|
var encoded: Dictionary = IO.encode_rules(rules, {10: "stickman_1"})
|
|
var decoded: Dictionary = IO.decode_rules(encoded["rules"], {"stickman_1": 99}, {})
|
|
_expect((decoded["rules"] as Array).size() == 2, "duplicate loaded rule IDs are reassigned")
|
|
_expect(int((decoded["rules"] as Array)[0]["id"]) != int((decoded["rules"] as Array)[1]["id"]), "reassigned rule IDs are unique")
|
|
var missing: Dictionary = IO.decode_rules(encoded["rules"], {}, {"stickman_1": true})
|
|
_expect(int(missing["skipped"]) == 2, "unavailable references skip whole rules")
|
|
|
|
|
|
func _test_stage_round_trip_and_file_state() -> void:
|
|
var path := "user://phase5_stage_roundtrip.scene"
|
|
var stage: Node2D = STAGE_SCENE.instantiate()
|
|
root.add_child(stage)
|
|
await process_frame
|
|
stage._reset_scene_content()
|
|
|
|
var terrain: TerrainBlock = stage._spawner.spawn_terrain_asset("ground", Vector2(20.0, 30.0))
|
|
terrain.rotation = deg_to_rad(12.0)
|
|
terrain.scale = Vector2(1.25, 0.75)
|
|
stage._finalize_placed_node(terrain)
|
|
var prop: PropBlock = stage._spawner.spawn_prop_asset("crate", Vector2(140.0, -20.0))
|
|
prop.rotation = deg_to_rad(-18.0)
|
|
stage._finalize_placed_node(prop)
|
|
var area: TriggerArea = stage._spawner.spawn_area(Vector2(300.0, 40.0))
|
|
area.size = Vector2(160.0, 80.0)
|
|
area.scale = Vector2(1.2, 0.8)
|
|
stage._finalize_placed_node(area)
|
|
var rig: StickmanRig = stage._spawner.spawn_stickman_asset("res://content/stickmen/test.stk", Vector2(-100.0, -385.0))
|
|
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
|
rig.queue_action({"type": "wait", "duration": 0.25})
|
|
stage._finalize_placed_node(rig)
|
|
stage._event_rules.assign([{
|
|
"id": 7,
|
|
"trigger": {"type": "entered_area", "source": rig.get_instance_id(), "target": area.get_instance_id(), "params": {}},
|
|
"actions": [{"type": "speak", "target": rig.get_instance_id(), "params": {"text": "Loaded", "duration": 1.0}}],
|
|
}])
|
|
stage._scene_name = "Round Trip"
|
|
stage._set_scene_dirty(true)
|
|
_expect(stage._save_scene_to_path(path), "stage document saves")
|
|
_expect(not stage._dirty and stage._current_scene_path == path, "manual save adopts path and clears dirty")
|
|
_expect(stage.get_window().title == "Round Trip — phase5_stage_roundtrip.scene — Stickman Studio", "title identifies scene name and active file")
|
|
stage._rename_scene_prompt()
|
|
await process_frame
|
|
_expect(stage._rename_scene_dialog is ConfirmationDialog and stage._rename_scene_dialog.get_cancel_button().visible, "Rename Scene has a Cancel button")
|
|
_expect(stage._scene_name_edit.has_focus() and stage._scene_name_edit.has_selection(), "Rename Scene focuses and selects the existing name")
|
|
_expect(stage._rename_scene_dialog.dialog_text.is_empty() and stage._scene_name_edit.placeholder_text == "Scene name", "Rename hint is shown only as an empty textbox placeholder")
|
|
stage._rename_scene_dialog.hide()
|
|
|
|
var saved: Dictionary = IO.read_file(path)["data"]
|
|
_expect((saved["objects"] as Array).size() == 4, "all four object types serialize")
|
|
var prop_entry: Dictionary = (saved["objects"] as Array)[1]
|
|
_expect(prop_entry["asset_id"] == "crate" and (prop_entry["material_preset"] is int or prop_entry["material_preset"] is float), "prop provenance and numeric material serialize")
|
|
_expect(String((saved["objects"] as Array)[3]["facing_profile"]) == "LEFT", "facing serializes by name")
|
|
_expect(absf(float((saved["objects"] as Array)[3]["rotation"])) < 0.01, "LEFT mirror is removed from authored rotation")
|
|
_expect(stage._load_scene_from_path(path), "stage document loads")
|
|
_expect(stage._world.get_child_count() == 4, "load reconstructs all objects")
|
|
_expect(stage._world.get_child(0) is TerrainBlock and (stage._world.get_child(0) as TerrainBlock).spawn_id == "ground", "World order and terrain provenance survive")
|
|
var loaded_prop := stage._world.get_child(1) as PropBlock
|
|
_expect(loaded_prop != null and loaded_prop.spawn_id == "crate" and loaded_prop.global_position.is_equal_approx(Vector2(140.0, -20.0)), "prop transform and provenance survive")
|
|
var loaded_area := stage._world.get_child(2) as TriggerArea
|
|
_expect(loaded_area != null and loaded_area.size == Vector2(160.0, 80.0) and loaded_area.scale.is_equal_approx(Vector2(1.2, 0.8)), "area size and scale survive")
|
|
var loaded_rig := stage._world.get_child(3) as StickmanRig
|
|
_expect(loaded_rig != null and loaded_rig.source_stk_path == "res://content/stickmen/test.stk" and loaded_rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT, "stickman provenance and facing survive")
|
|
_expect(absf((loaded_rig.global_transform * Transform2D.FLIP_X).get_rotation()) < 0.01, "LEFT-facing rig loads with one mirror and canonical rotation")
|
|
_expect(loaded_rig.get_queue().size() == 1 and stage._event_rules.size() == 1, "queue and rule survive")
|
|
var loaded_trigger: Dictionary = stage._event_rules[0]["trigger"]
|
|
_expect(int(loaded_trigger["source"]) == loaded_rig.get_instance_id() and int(loaded_trigger["target"]) == loaded_area.get_instance_id(), "loaded rules resolve to new runtime IDs")
|
|
stage._update_recent_menu()
|
|
var recent_popup := stage._file_menu.get_popup().get_node("OpenRecent") as PopupMenu
|
|
_expect(stage._recent_scenes[0] == path and recent_popup.get_item_text(0) == path, "successful load updates Open Recent")
|
|
stage._recent_scenes.append("user://missing_recent_phase5.scene")
|
|
stage._update_recent_menu()
|
|
_expect(not stage._recent_scenes.has("user://missing_recent_phase5.scene"), "Open Recent prunes missing files")
|
|
|
|
stage._camera.position += Vector2(10.0, 5.0)
|
|
_expect(not stage._dirty, "camera movement does not dirty document")
|
|
stage._mark_scene_dirty()
|
|
_expect(stage._dirty and "Round Trip" in stage._status_label.text and "*" in stage.get_window().title, "dirty state updates status and title")
|
|
var object_count_before_cancel: int = stage._world.get_child_count()
|
|
stage._request_pending_file_action(stage.PendingFileAction.NEW_SCENE)
|
|
stage._unsaved_changes_dialog.canceled.emit()
|
|
stage._unsaved_changes_dialog.hide()
|
|
_expect(stage._pending_file_action == stage.PendingFileAction.NONE and stage._world.get_child_count() == object_count_before_cancel, "Cancel aborts a pending destructive action")
|
|
var adopted_path: String = stage._current_scene_path
|
|
_expect(stage._save_scene_to_path(stage.AUTOSAVE_PATH, true), "autosave snapshot writes")
|
|
_expect(stage._dirty and stage._current_scene_path == adopted_path, "autosave does not clear dirty or adopt autosave path")
|
|
|
|
var missing_data := {
|
|
"version": "1.0",
|
|
"scene_name": "Missing",
|
|
"objects": [{
|
|
"file_id": "stickman_1",
|
|
"type": "stickman",
|
|
"stk_path": "user://definitely_missing_phase5.stk",
|
|
"position": {"x": 25.0, "y": 50.0},
|
|
"rotation": 0.0,
|
|
"facing_profile": "FORWARD",
|
|
"action_queue": [{"type": "wait", "duration": 1.0}],
|
|
}],
|
|
"event_rules": [],
|
|
"metadata": {},
|
|
}
|
|
_expect(stage._apply_scene_data(missing_data, path, false), "missing-stickman document loads tolerantly")
|
|
var placeholder: Node = stage._world.get_child(0)
|
|
_expect(placeholder.get_script() == MISSING_PLACEHOLDER and "1 missing stickman" in stage._toast_text, "missing .stk creates a summarized placeholder")
|
|
_expect(placeholder.get_area_rect().size.x >= 200.0 and "definitely_missing_phase5.stk" in stage._missing_assets_dialog.dialog_text, "missing .stk gets a labeled red marker and load-error dialog")
|
|
stage._missing_assets_dialog.hide()
|
|
var placeholder_saved: Dictionary = stage._collect_scene_data()
|
|
_expect(String((placeholder_saved["objects"] as Array)[0]["stk_path"]) == "user://definitely_missing_phase5.stk", "placeholder re-save preserves missing path")
|
|
|
|
stage._set_scene_dirty(true)
|
|
stage._request_pending_file_action(stage.PendingFileAction.NEW_SCENE)
|
|
_expect(stage._pending_file_action == stage.PendingFileAction.NEW_SCENE and stage._unsaved_changes_dialog.visible, "dirty New opens pending unsaved prompt")
|
|
stage._on_unsaved_discard()
|
|
_expect(stage._world.get_child_count() == 0 and not stage._dirty and stage._current_scene_path.is_empty() and not stage._unsaved_changes_dialog.visible, "Discard closes its prompt and resets the document")
|
|
|
|
stage.queue_free()
|
|
await process_frame
|
|
DirAccess.remove_absolute(path)
|
|
if FileAccess.file_exists("user://sandbox_autosave.scene"):
|
|
DirAccess.remove_absolute("user://sandbox_autosave.scene")
|