extends SceneTree const IO := preload("res://scripts/stage_scene_io.gd") const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const MISSING_PLACEHOLDER := preload("res://scripts/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://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://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")