Files
stickman/tests/test_phase5_file_features.gd
T

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")