Merge remote-tracking branch 'origin/feature/prop-editor'

This commit is contained in:
2026-09-19 23:57:12 -04:00
34 changed files with 7021 additions and 2 deletions
+387
View File
@@ -0,0 +1,387 @@
# Phase 9 Prop Editor - headless regression checks for PropCanvas math
# (vertex insert/remove, mirror, z-order, clipboard, transforms, sanitize).
# No GUI event injection; the public math APIs are driven directly.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_canvas.gd"
extends SceneTree
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
var _checks: int = 0
var _failures: int = 0
var _changed_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP CANVAS TEST ===")
await _test_primitives()
await _test_vertex_insert_remove()
await _test_mirror()
await _test_z_order()
await _test_clipboard()
await _test_transforms()
await _test_sanitize_and_radius()
await _test_import_export_and_clear()
await _test_edit_mode()
await _test_gizmo_constants()
await _test_create_point_on_edge()
await _test_segment_hit_test()
await _test_reshaped_flag()
await _test_selection_halo()
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 check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _new_canvas() -> PropCanvas:
var canvas: PropCanvas = PROP_CANVAS.new()
root.add_child(canvas)
canvas.size = Vector2(800, 600)
_changed_count = 0
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: _changed_count += 1)
return canvas
func _free_canvas(canvas: PropCanvas) -> void:
canvas.queue_free()
await process_frame
func _bbox(pts: Array) -> Rect2:
var first: Dictionary = pts[0]
var bbox := Rect2(Vector2(float(first.get("x", 0.0)), float(first.get("y", 0.0))), Vector2.ZERO)
for p: Dictionary in pts:
bbox = bbox.expand(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
return bbox
func _export_points(sd: Dictionary) -> Array:
return sd.get("points", []) as Array
func _test_primitives() -> void:
var canvas: PropCanvas = _new_canvas()
var box: Dictionary = canvas.add_primitive("box", Vector2(100, 100))
check("box appended", canvas.shapes.size() == 1)
check("box type", str(box.get("shape_type", "")) == "polygon")
check("box 4 points", _export_points(box).size() == 4)
check("box closed", bool(box.get("closed", false)))
check("add emits shape_changed", _changed_count == 1)
check("box selected", canvas.get_selected_index() == 0)
var big: Dictionary = canvas.add_primitive("box", Vector2(200, 200), {"size_x": 100.0, "size_y": 100.0})
var big_bbox: Rect2 = _bbox(_export_points(big))
check("box params respected", is_equal_approx(big_bbox.size.x, 100.0) and is_equal_approx(big_bbox.size.y, 100.0))
var ball: Dictionary = canvas.add_primitive("ball", Vector2(300, 300))
check("ball type", str(ball.get("shape_type", "")) == "circle")
check("ball radius default", is_equal_approx(float(ball.get("radius", 0.0)), 24.0))
check("ball points 12", _export_points(ball).size() == 12)
var line: Dictionary = canvas.add_primitive("line", Vector2(400, 400))
check("line type", str(line.get("shape_type", "")) == "line")
check("line open", not bool(line.get("closed", true)))
check("line 2 points", _export_points(line).size() == 2)
var plank: Dictionary = canvas.add_primitive("plank", Vector2(500, 500))
check("plank polygon", str(plank.get("shape_type", "")) == "polygon")
var triangle: Dictionary = canvas.add_primitive("triangle", Vector2(600, 600))
check("triangle 3 points", _export_points(triangle).size() == 3)
check("triangle closed", bool(triangle.get("closed", false)))
var circle: Dictionary = canvas.add_primitive("circle", Vector2(700, 700), {"radius": 50.0})
check("circle radius param", is_equal_approx(float(circle.get("radius", 0.0)), 50.0))
await _free_canvas(canvas)
func _test_vertex_insert_remove() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.insert_vertex(0, 2, Vector2(124, 100))
check("insert grows point list", _export_points(canvas.get_shape_data()[0]).size() == 5)
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[2]) == 1)
check("original vertices stay 0", int(flags[0]) == 0 and int(flags[4]) == 0)
canvas.remove_vertex(0, 2)
check("remove shrinks point list", _export_points(canvas.get_shape_data()[0]).size() == 4)
check("flags aligned after remove", (canvas.get_shape_data()[0].get("vertex_flags", []) as Array).size() == 4)
canvas.remove_vertex(0, 0)
canvas.remove_vertex(0, 0)
check("polygon with 2 points becomes open", bool(canvas.shapes[0].get("closed", true)) == false)
canvas.remove_vertex(0, 0)
check("below 2 vertices drops shape", canvas.shapes.size() == 0)
check("selection cleared after drop", canvas.get_selected_index() == -1)
await _free_canvas(canvas)
func _test_mirror() -> void:
var canvas: PropCanvas = _new_canvas()
var tri: Dictionary = canvas.add_primitive("triangle", Vector2(100, 100))
var before: Array = _export_points(tri)
var bbox: Rect2 = _bbox(before)
canvas.mirror_x(0)
var after_x: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_x flips x about center", is_equal_approx(float(after_x[0].get("x", 0.0)), 2.0 * bbox.get_center().x - float(before[0].get("x", 0.0))))
check("mirror_x keeps y", is_equal_approx(float(after_x[0].get("y", 0.0)), float(before[0].get("y", 0.0))))
check("mirror_x keeps bbox center", _bbox(after_x).get_center().is_equal_approx(bbox.get_center()))
canvas.mirror_y(0)
var after_y: Array = _export_points(canvas.get_shape_data()[0])
check("mirror_y flips y about center", is_equal_approx(float(after_y[0].get("y", 0.0)), 2.0 * bbox.get_center().y - float(after_x[0].get("y", 0.0))))
check("mirror_y keeps bbox center", _bbox(after_y).get_center().is_equal_approx(bbox.get_center()))
await _free_canvas(canvas)
func _test_z_order() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(0, 0), {"fill_color": "#FF0000", "outline_color": "#000000"})
canvas.add_primitive("ball", Vector2(100, 0), {"fill_color": "#00FF00", "outline_color": "#000000"})
canvas.add_primitive("line", Vector2(200, 0), {"fill_color": "#0000FF", "outline_color": "#000000"})
canvas.bring_forward(1)
check("bring_forward swaps up", str(canvas.get_shape_data()[1].get("shape_type", "")) == "line")
check("bring_forward swaps down", str(canvas.get_shape_data()[2].get("shape_type", "")) == "circle")
canvas.send_back(2)
check("send_back swaps down", str(canvas.get_shape_data()[1].get("shape_type", "")) == "circle")
canvas.bring_forward(2)
check("bring_forward at top is no-op", str(canvas.get_shape_data()[2].get("shape_type", "")) == "line")
canvas.send_back(0)
check("send_back at bottom is no-op", str(canvas.get_shape_data()[0].get("shape_type", "")) == "polygon")
await _free_canvas(canvas)
func _test_clipboard() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(50, 50), {"fill_color": "#123456", "outline_color": "#000000"})
var copied: Dictionary = canvas.copy_shape(0)
check("copy returns deep dict", not copied.is_empty() and copied.has("points"))
canvas.set_shape_fill_color(0, "#ABCDEF")
check("copy is independent", str(copied.get("fill_color", "")) == "#123456")
canvas.paste_shape(copied, Vector2(100, 100))
check("paste appends", canvas.shapes.size() == 2)
var pasted_pts: Array = _export_points(canvas.get_shape_data()[1])
var pasted_bbox: Rect2 = _bbox(pasted_pts)
check("paste offsets by (16,16)", pasted_bbox.get_center().is_equal_approx(Vector2(116, 116)))
check("paste preserves color", str(canvas.get_shape_data()[1].get("fill_color", "")) == "#123456")
check("paste selects new shape", canvas.get_selected_index() == 1)
await _free_canvas(canvas)
func _test_transforms() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
canvas.translate_selected(Vector2(10, -5))
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("translate moves all vertices", _bbox(pts).get_center().is_equal_approx(Vector2(110, 95)))
canvas.add_primitive("box", Vector2(200, 200))
canvas.select(1)
canvas.rotate_selected(PI / 2.0)
var rotated_pts: Array = _export_points(canvas.get_shape_data()[1])
# Box corners at (224,224) rotate 90 degrees ccw about (200,200) -> (176,224).
var found: bool = false
for p: Dictionary in rotated_pts:
if Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))).is_equal_approx(Vector2(176, 224)):
found = true
check("rotate 90 degrees about bbox center", found)
check("rotate keeps bbox center", _bbox(rotated_pts).get_center().is_equal_approx(Vector2(200, 200)))
canvas.scale_selected(Vector2(2.0, 1.0))
var scaled_pts: Array = _export_points(canvas.get_shape_data()[1])
var scaled_bbox: Rect2 = _bbox(scaled_pts)
check("scale stretches width", is_equal_approx(scaled_bbox.size.x, 96.0))
check("scale keeps height", is_equal_approx(scaled_bbox.size.y, 48.0))
await _free_canvas(canvas)
func _test_sanitize_and_radius() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_grid_size(16)
canvas.set_snap_enabled(true)
# Rectangle with one collinear point and one off-grid point; sanitize must
# grid-snap, drop the collinear vertex, and enforce clockwise winding.
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [
{"x": 0.0, "y": 0.0},
{"x": 16.0, "y": 0.0},
{"x": 32.0, "y": 0.0},
{"x": 32.0, "y": 32.0},
{"x": 0.0, "y": 33.0},
],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}])
canvas.sanitize_shape(0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("sanitize drops collinear vertex", pts.size() == 4)
var packed := PackedVector2Array()
for p: Dictionary in pts:
packed.append(Vector2(float(p.get("x", 0.0)), float(p.get("y", 0.0))))
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(packed))
var min_y: float = INF
var max_y: float = -INF
for v: Vector2 in packed:
min_y = minf(min_y, v.y)
max_y = maxf(max_y, v.y)
check("sanitize grid-snaps", is_equal_approx(min_y, 0.0) and is_equal_approx(max_y, 32.0))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("sanitize keeps flags aligned", flags.size() == 4)
var circle: Dictionary = canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
var before_count: int = _export_points(circle).size()
canvas.sanitize_shape(1)
check("sanitize skips circles", _export_points(canvas.get_shape_data()[1]).size() == before_count)
canvas.set_shape_radius(1, 80.0)
var radius_pts: Array = _export_points(canvas.get_shape_data()[1])
check("set_shape_radius resizes points", is_equal_approx(float(canvas.get_shape_data()[1].get("radius", 0.0)), 80.0))
var radius_bbox: Rect2 = _bbox(radius_pts)
check("radius bbox matches", is_equal_approx(radius_bbox.size.x, 160.0) and is_equal_approx(radius_bbox.size.y, 160.0))
await _free_canvas(canvas)
func _test_import_export_and_clear() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.set_shape_data([{
"shape_type": "polygon",
"points": [{"x": 0.0, "y": 0.0}, {"x": 10.0, "y": 0.0}, {"x": 10.0, "y": 10.0}],
"fill_color": "#111111",
"outline_color": "#222222",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0],
}])
check("import plain {x,y} data", canvas.shapes.size() == 1)
check("import preserves flags", (canvas.shapes[0].get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var exported: Array[Dictionary] = canvas.get_shape_data()
check("export plain format", exported.size() == 1 and (exported[0].get("points", []) as Array)[0] is Dictionary)
var before_changed: int = _changed_count
canvas.clear()
check("clear empties shapes", canvas.shapes.is_empty())
check("clear emits no shape_changed", _changed_count == before_changed)
check("export after clear empty", canvas.get_shape_data().is_empty())
await _free_canvas(canvas)
func _test_edit_mode() -> void:
var canvas: PropCanvas = _new_canvas()
check("default mode is TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
var mode_fires: Array = [0]
canvas.mode_changed.connect(func(_mode: int) -> void: mode_fires[0] += 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("set_edit_mode(POINTS) applies", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
check("mode_changed fired once on change", int(mode_fires[0]) == 1)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
check("re-setting same mode does not fire", int(mode_fires[0]) == 1)
canvas.set_edit_mode(99)
check("unknown mode clamps to TRANSFORM", canvas.get_edit_mode() == PROP_CANVAS.EditMode.TRANSFORM)
check("clamp fires mode_changed", int(mode_fires[0]) == 2)
canvas.set_edit_mode(PROP_CANVAS.EditMode.POINTS)
canvas.add_primitive("box", Vector2(100, 100))
canvas.clear()
check("clear keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.set_shape_data([])
check("set_shape_data keeps the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
canvas.select(0)
canvas.deselect()
check("select/deselect keep the mode", canvas.get_edit_mode() == PROP_CANVAS.EditMode.POINTS)
await _free_canvas(canvas)
func _test_gizmo_constants() -> void:
check("rotation dot radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_RADIUS, 5.0))
check("rotation dot hit radius constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_HIT_RADIUS, 12.0))
check("rotation dot offset constant", is_equal_approx(PROP_CANVAS.ROTATION_CIRCLE_OFFSET, 26.0))
check("scale cross size constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_SIZE, 5.0))
check("scale cross hit radius constant", is_equal_approx(PROP_CANVAS.SCALE_CROSS_HIT_RADIUS, 12.0))
check("vertex handle radius constant", is_equal_approx(PROP_CANVAS.HANDLE_RADIUS, 3.0))
var constant_map: Dictionary = (PROP_CANVAS as Script).get_script_constant_map()
check("rotate ring constant removed", not constant_map.has("ROTATE_RING_RADIUS"))
check("scale circle constant removed", not constant_map.has("SCALE_HANDLE_DISTANCE"))
func _test_create_point_on_edge() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
var created: Array = []
canvas.vertex_created.connect(func(s_idx: int, v_idx: int) -> void: created.append([s_idx, v_idx]))
# Cursor at (110, 60): the nearest segment is the top edge (76,76)-(124,76);
# the projection onto it is (110, 76) - NOT the edge midpoint (100, 76).
var idx: int = canvas.create_point_on_edge(Vector2(110, 60))
check("create_point_on_edge returns valid index", idx >= 0)
var pts: Array = _export_points(canvas.get_shape_data()[0])
check("create point grows the vertex list", pts.size() == 5)
var inserted: Dictionary = pts[idx]
check("inserted point is the cursor projection", Vector2(float(inserted.get("x", 0.0)), float(inserted.get("y", 0.0))).is_equal_approx(Vector2(110, 76)))
var flags: Array = canvas.get_shape_data()[0].get("vertex_flags", []) as Array
check("inserted vertex flagged USER_CREATED", int(flags[idx]) == 1)
check("vertex_created emitted with shape and vertex index", created.size() == 1 and int(created[0][0]) == 0 and int(created[0][1]) == idx)
await _free_canvas(canvas)
func _test_segment_hit_test() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("box", Vector2(100, 100))
check("segment hit above top edge", canvas.hit_test_segment(Vector2(110, 70)) == 0)
check("segment hit left of left edge", canvas.hit_test_segment(Vector2(70, 90)) == 0)
check("segment hit misses interior", canvas.hit_test_segment(Vector2(100, 100)) == -1)
check("segment hit misses empty canvas", canvas.hit_test_segment(Vector2(400, 400)) == -1)
canvas.add_primitive("line", Vector2(300, 300))
check("segment hit on open line", canvas.hit_test_segment(Vector2(300, 306)) == 1)
canvas.select(1)
check("segment hit frontmost wins", canvas.hit_test_segment(Vector2(300, 306)) == 1)
await _free_canvas(canvas)
func _test_reshaped_flag() -> void:
var canvas: PropCanvas = _new_canvas()
canvas.add_primitive("circle", Vector2(100, 100), {"radius": 40.0})
check("new circle not reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
check("export carries reshaped false", not bool(canvas.get_shape_data()[0].get("reshaped", true)))
canvas.insert_vertex(0, 0, Vector2(60, 60))
check("insert vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.set_shape_radius(0, 60.0)
check("set_shape_radius clears reshaped", not bool(canvas.shapes[0].get("reshaped", true)))
canvas.remove_vertex(0, 0)
check("remove vertex reshapes", bool(canvas.shapes[0].get("reshaped", false)))
canvas.add_primitive("circle", Vector2(300, 300), {"radius": 40.0})
canvas.select(1)
canvas.scale_selected(Vector2(2.0, 2.0))
check("uniform scale keeps circle primitive", not bool(canvas.shapes[1].get("reshaped", true)))
canvas.scale_selected(Vector2(2.0, 1.0))
check("non-uniform scale reshapes circle", bool(canvas.shapes[1].get("reshaped", false)))
canvas.add_primitive("box", Vector2(500, 500))
canvas.select(2)
canvas.translate_selected(Vector2(10, 0))
check("translate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.rotate_selected(0.5)
check("rotate does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.mirror_x(2)
check("mirror does not reshape", not bool(canvas.shapes[2].get("reshaped", true)))
canvas.insert_vertex(2, 0, Vector2(400, 400))
check("vertex insert reshapes polygon", bool(canvas.shapes[2].get("reshaped", false)))
await _free_canvas(canvas)
func _test_selection_halo() -> void:
check("selection halo is dark", PROP_CANVAS.SELECTION_HALO_COLOR.get_luminance() < 0.3)
check("selection core is bright", PROP_CANVAS.SELECTION_CORE_COLOR.get_luminance() > 0.7)
+1
View File
@@ -0,0 +1 @@
uid://yr00a8ban8vp
+252
View File
@@ -0,0 +1,252 @@
# Phase 9 Prop Editor - headless regression checks for the .prop schema
# (PropFile: round-trip, version rejection, defaults, flattening, sanitize).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_file.gd"
extends SceneTree
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP FILE TEST ===")
await _test_preset_conversion()
await _test_to_file_dict()
await _test_round_trip()
await _test_version_rejection()
await _test_missing_key_defaults()
await _test_flattening()
await _test_sanitize_shape()
await _test_save_load()
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 check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _hex(v: Vector2) -> Dictionary:
return {"x": v.x, "y": v.y}
func _box_shape(points: Array, fill: String = "#112233", outline: String = "#445566") -> Dictionary:
return {
"shape_type": "polygon",
"points": points,
"fill_color": fill,
"outline_color": outline,
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 0, 0],
}
func _test_preset_conversion() -> void:
for i: int in PROP_FILE.PRESET_STRINGS.size():
var s: String = PROP_FILE.preset_to_string(i)
check("preset_to_string(%d) == PRESET_STRINGS[%d]" % [i, i], s == PROP_FILE.PRESET_STRINGS[i])
check("preset_from_string round trip %s" % s, PROP_FILE.preset_from_string(s) == i)
check("preset_from_string unknown -> NONE", PROP_FILE.preset_from_string("banana") == PROP_BLOCK.MaterialPreset.NONE)
check("preset_from_string case-insensitive", PROP_FILE.preset_from_string("WOOD") == PROP_BLOCK.MaterialPreset.WOOD)
check("preset_to_string clamps low", PROP_FILE.preset_to_string(-5) == PROP_FILE.PRESET_STRINGS[0])
check("preset_to_string clamps high", PROP_FILE.preset_to_string(99) == PROP_FILE.PRESET_STRINGS[PROP_FILE.PRESET_STRINGS.size() - 1])
check("shape_type_to_payload_type circle", PROP_FILE.shape_type_to_payload_type("circle") == PROP_BLOCK.ShapeType.CIRCLE)
check("shape_type_to_payload_type polygon", PROP_FILE.shape_type_to_payload_type("polygon") == PROP_BLOCK.ShapeType.POLYGON)
check("shape_type_to_payload_type unknown", PROP_FILE.shape_type_to_payload_type("line") == PROP_BLOCK.ShapeType.POLYGON)
func _test_to_file_dict() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("My Crate", PROP_BLOCK.MaterialPreset.WOOD, shapes)
check("to_file_dict version", str(data.get("version", "")) == PROP_FILE.FILE_VERSION)
check("to_file_dict prop_name", str(data.get("prop_name", "")) == "My Crate")
check("to_file_dict preset string", str(data.get("material_preset", "")) == "wood")
check("to_file_dict shapes count", (data.get("shapes", []) as Array).size() == 1)
var meta: Dictionary = data.get("metadata", {})
check("to_file_dict metadata present", not meta.is_empty())
var serialized: Dictionary = (data.get("shapes", []) as Array)[0]
check("serialized points are {x,y} dicts", serialized.get("points", []) is Array and ((serialized.get("points", []) as Array)[0] as Dictionary).has("x"))
check("serialized point values preserved", is_equal_approx(float(((serialized.get("points", []) as Array)[1] as Dictionary).get("x", -1.0)), 64.0))
check("serialized flags array", (serialized.get("vertex_flags", []) as Array).size() == 4)
func _test_round_trip() -> void:
var shapes: Array[Dictionary] = []
shapes.append(_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))]))
shapes.append({
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 3.0,
"closed": true,
"vertex_flags": [],
})
shapes.append({
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#00FF00",
"outline_color": "#0000FF",
"outline_width": 1.5,
"closed": false,
"vertex_flags": [0, 0],
"reshaped": true,
})
var data: Dictionary = PROP_FILE.to_file_dict("Round Trip", PROP_BLOCK.MaterialPreset.METAL, shapes)
var model: Dictionary = PROP_FILE.from_file_dict(data)
check("round trip non-empty", not model.is_empty())
check("round trip name", str(model.get("prop_name", "")) == "Round Trip")
check("round trip preset", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.METAL)
var parsed: Array = model.get("shapes", [])
check("round trip shapes count", parsed.size() == 3)
var poly: Dictionary = parsed[0]
check("round trip polygon points", (poly.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("round trip polygon point values", ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[1]).is_equal_approx(Vector2(64, 0)) and ((poly.get("points", PackedVector2Array()) as PackedVector2Array)[2]).is_equal_approx(Vector2(64, 64)))
check("round trip polygon closed", bool(poly.get("closed", false)) == true)
check("round trip polygon fill", str(poly.get("fill_color", "")) == "#112233")
var circ: Dictionary = parsed[1]
check("round trip circle type", str(circ.get("shape_type", "")) == "circle")
check("round trip circle radius", is_equal_approx(float(circ.get("radius", 0.0)), 40.0))
check("round trip circle points rebuilt", (circ.get("points", PackedVector2Array()) as PackedVector2Array).size() == PROP_FILE.DEFAULT_CIRCLE_SEGMENTS)
var line: Dictionary = parsed[2]
check("round trip line closed", bool(line.get("closed", true)) == false)
check("round trip line width", is_equal_approx(float(line.get("outline_width", 0.0)), 1.5))
check("round trip reshaped preserved", bool(line.get("reshaped", false)) == true)
check("round trip reshaped defaults false", bool(poly.get("reshaped", true)) == false)
func _test_version_rejection() -> void:
check("reject missing version", PROP_FILE.from_file_dict({"prop_name": "x"}).is_empty())
check("reject future version", PROP_FILE.from_file_dict({"version": "9.9", "shapes": []}).is_empty())
check("reject non-dict data", PROP_FILE.from_file_dict([1, 2, 3]).is_empty())
check("accept json string", not PROP_FILE.from_file_dict(JSON.stringify({"version": "1.0", "shapes": []})).is_empty())
func _test_missing_key_defaults() -> void:
var data := {
"version": "1.0",
"shapes": [
{"shape_type": "polygon", "points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(16, 16))]},
{"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0))]},
],
}
var model: Dictionary = PROP_FILE.from_file_dict(data)
var shapes: Array = model.get("shapes", [])
check("defaults shapes parsed", shapes.size() == 2)
check("default preset NONE", int(model.get("material_preset", -1)) == PROP_BLOCK.MaterialPreset.NONE)
var s0: Dictionary = shapes[0]
check("default closed for polygon", bool(s0.get("closed", false)) == true)
check("default fill from tint", str(s0.get("fill_color", "")).to_lower() == PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.NONE).to_html().to_lower())
check("default outline darkened", str(s0.get("outline_color", "")) != "")
check("default width", is_equal_approx(float(s0.get("outline_width", 0.0)), 2.0))
check("default flags sized", (s0.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 3)
var s1: Dictionary = shapes[1]
check("missing shape_type -> polygon", str(s1.get("shape_type", "")) == "polygon")
func _test_flattening() -> void:
check("flatten empty -> {}", PROP_FILE.flatten_to_payload([]).is_empty())
var line_shape := {
"shape_type": "line",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(64, 0))],
"fill_color": "#AABBCC",
"outline_color": "#112233",
"outline_width": 2.0,
"closed": false,
"vertex_flags": [0, 0],
}
var poly_back := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))], "#101010", "#202020")
var poly_front := _box_shape([_hex(Vector2(0, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))], "#FEFEFE", "#0F0F0F")
var circle_shape := {
"shape_type": "circle",
"points": [],
"radius": 40.0,
"fill_color": "#FF0000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
var payload: Dictionary = PROP_FILE.flatten_to_payload([line_shape, poly_back, poly_front])
check("flatten prefers frontmost closed polygon", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON and (payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#FEFEFE")))
payload = PROP_FILE.flatten_to_payload([line_shape, circle_shape])
check("flatten falls back to circle", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.CIRCLE)
check("flatten circle radius", is_equal_approx(float(payload.get("radius", 0.0)), 40.0))
payload = PROP_FILE.flatten_to_payload([line_shape])
check("flatten line -> polygon payload", int(payload.get("type", -1)) == PROP_BLOCK.ShapeType.POLYGON)
var pts: PackedVector2Array = payload.get("points", PackedVector2Array())
check("flatten line points sanitized to grid", pts.size() == 2 and pts[0].x == 0.0 and pts[1].x == 64.0)
var z_payload: Dictionary = PROP_FILE.flatten_to_payload([poly_front, poly_back])
check("flatten z-order: last closed polygon wins", (z_payload.get("fill_color", Color.BLACK) as Color).is_equal_approx(Color("#101010")))
func _test_sanitize_shape() -> void:
var dirty := {
"shape_type": "polygon",
"points": [_hex(Vector2(16, 16)), _hex(Vector2(16, 0)), _hex(Vector2(16, 0)), _hex(Vector2(0, 0)), _hex(Vector2(0, 16))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 0, 1, 0, 0],
}
var cleaned: Dictionary = PROP_FILE.sanitize_shape(dirty)
var pts: PackedVector2Array = cleaned.get("points", PackedVector2Array())
check("sanitize drops consecutive duplicate", pts.size() == 4)
check("sanitize enforces clockwise", Geometry2D.is_polygon_clockwise(pts))
var collinear := {
"shape_type": "polygon",
"points": [_hex(Vector2(0, 0)), _hex(Vector2(16, 0)), _hex(Vector2(32, 0)), _hex(Vector2(32, 32)), _hex(Vector2(0, 32))],
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [0, 1, 0, 0, 0],
}
var cleaned2: Dictionary = PROP_FILE.sanitize_shape(collinear)
check("sanitize drops collinear middle vertex", (cleaned2.get("points", PackedVector2Array()) as PackedVector2Array).size() == 4)
check("sanitize flags aligned", (cleaned2.get("vertex_flags", PackedInt32Array()) as PackedInt32Array).size() == 4)
var circle := {
"shape_type": "circle",
"points": [],
"radius": 24.0,
"fill_color": "#000000",
"outline_color": "#000000",
"outline_width": 2.0,
"closed": true,
"vertex_flags": [],
}
check("sanitize passes circles through", is_equal_approx(float(PROP_FILE.sanitize_shape(circle).get("radius", 0.0)), 24.0))
func _test_save_load() -> void:
var shapes: Array[Dictionary] = [_box_shape([_hex(Vector2(0, 0)), _hex(Vector2(64, 0)), _hex(Vector2(64, 64)), _hex(Vector2(0, 64))])]
var data: Dictionary = PROP_FILE.to_file_dict("Save Test", PROP_BLOCK.MaterialPreset.RUBBER, shapes)
var path := "user://prop_editor_test_save.prop"
check("save returns true", PROP_FILE.save(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("load returns dict", not loaded.is_empty())
check("load matches", loaded.get("prop_name", "") == "Save Test" and loaded.get("material_preset", "") == "rubber")
check("load missing -> {}", PROP_FILE.load("user://definitely_missing.prop").is_empty())
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://drq2g06sh58xs
+89
View File
@@ -0,0 +1,89 @@
# Phase 9 Prop Editor Round 3 - headless regression checks for the pure static
# helpers (window title, mode button text) and PropFile.save_safe atomic writes.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_round3.gd"
extends SceneTree
const PROP_EDITOR := preload("res://scripts/prop_editor.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP EDITOR ROUND 3 TEST ===")
await _test_window_title()
await _test_mode_button_text()
await _test_save_safe()
await _test_recent_list()
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 check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_window_title() -> void:
check("untitled no path no dirty", PROP_EDITOR.build_window_title("", "", false) == "Untitled")
check("name dirty", PROP_EDITOR.build_window_title("My Crate", "", true) == "My Crate *")
check("name path clean", PROP_EDITOR.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop")
check("untitled path dirty", PROP_EDITOR.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *")
check("whitespace name falls back", PROP_EDITOR.build_window_title(" ", "", false) == "Untitled")
check("extension kept in title", PROP_EDITOR.build_window_title("X", "res://props/ball.prop", false).ends_with("ball.prop"))
func _test_mode_button_text() -> void:
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
check("reshape label", PROP_EDITOR.mode_button_text(1) == "✏️ Reshape")
check("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "✏️ Reshape")
check("negative clamps to first", PROP_EDITOR.mode_button_text(-5) == "🖐️ Arrange")
func _test_save_safe() -> void:
var path := "user://round3_autosave_test.prop"
var data := {
"version": "1.0",
"prop_name": "X",
"material_preset": "none",
"shapes": [],
"metadata": {"autosave": true, "source_path": "", "modified_unix": 123},
}
check("save_safe first write", PROP_FILE.save_safe(path, data))
var loaded: Dictionary = PROP_FILE.load(path)
check("save_safe round trip", not loaded.is_empty() and loaded.get("prop_name", "") == "X" and bool((loaded.get("metadata", {}) as Dictionary).get("autosave", false)))
check("no tmp leftover", not FileAccess.file_exists(path + ".tmp"))
check("no bak leftover", not FileAccess.file_exists(path + ".bak"))
data["prop_name"] = "Y"
check("save_safe overwrite", PROP_FILE.save_safe(path, data))
var loaded2: Dictionary = PROP_FILE.load(path)
check("overwrite persisted", loaded2.get("prop_name", "") == "Y")
check("no leftovers after overwrite", not FileAccess.file_exists(path + ".tmp") and not FileAccess.file_exists(path + ".bak"))
DirAccess.remove_absolute(path)
func _test_recent_list() -> void:
var list: Array = PROP_EDITOR.update_recent_list([], "res://props/a.prop", 5)
check("recent empty list inserts", list == ["res://props/a.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/a.prop"], "res://props/a.prop", 5)
check("recent dedupe moves to front", list == ["res://props/a.prop", "res://props/b.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop"], "res://props/a.prop", 5)
check("recent insert keeps most-recent-first", list == ["res://props/a.prop", "res://props/b.prop", "res://props/c.prop"])
list = PROP_EDITOR.update_recent_list(["res://props/b.prop", "res://props/c.prop", "res://props/d.prop", "res://props/e.prop", "res://props/f.prop"], "res://props/a.prop", 5)
check("recent trims to max", list.size() == 5 and list[0] == "res://props/a.prop" and list[4] == "res://props/e.prop")
+1
View File
@@ -0,0 +1 @@
uid://bc82lccw6jtf
+170
View File
@@ -0,0 +1,170 @@
# Phase 9 Prop Editor - headless regression checks for PropTheme
# (fallback hierarchy, invalid-input fallback, physics-not-themeable).
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_theme.gd"
extends SceneTree
const PROP_THEME := preload("res://scripts/prop_theme.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THEME TEST ===")
await _test_default_file()
await _test_missing_file_fallback()
await _test_custom_override_and_fallback()
await _test_invalid_color_fallback()
await _test_invalid_number_fallback()
await _test_physics_not_themeable()
await _test_primitive_defaults()
await _test_preset_clamp()
await _test_ui_tokens()
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 check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_default_file() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from(PROP_THEME.DEFAULT_PATH)
check("committed theme loads", loaded)
check("grid_size default 15", theme.grid_size() == 15)
check("outline width default 2.0", is_equal_approx(theme.outline_width_default(), 2.0))
check("outline width min 0.5", is_equal_approx(theme.outline_width_min(), 0.5))
check("outline width max 32.0", is_equal_approx(theme.outline_width_max(), 32.0))
check("wood fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("wood outline from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(Color.from_string("#40290F", Color.BLACK)))
check("none fill from theme", theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill").is_equal_approx(Color.from_string("#8C6133", Color.BLACK)))
check("canvas bg color", theme.canvas_background_color().is_equal_approx(Color.from_string("#1F1F1F", Color.BLACK)))
func _test_missing_file_fallback() -> void:
var theme: PropTheme = PROP_THEME.new()
var loaded: bool = theme.load_from("res://definitely_missing_theme.json")
check("missing file reports false", not loaded)
check("missing file falls back to defaults", theme.grid_size() == 15 and is_equal_approx(theme.outline_width_default(), 2.0))
check("missing file keeps material tints", theme.material_tint(PROP_BLOCK.MaterialPreset.RUBBER, "fill").is_equal_approx(Color.from_string("#E63333", Color.BLACK)))
func _write_user_theme(text: String, path: String) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(text)
f.close()
func _test_custom_override_and_fallback() -> void:
var path := "user://prop_theme_test_custom.json"
_write_user_theme('{"version":"1.0","canvas":{"grid_size":33},"outline_widths":{"default":5.0}}', path)
var theme: PropTheme = PROP_THEME.new()
check("custom theme loads", theme.load_from(path))
check("custom grid override", theme.grid_size() == 33)
check("custom width override", is_equal_approx(theme.outline_width_default(), 5.0))
check("unoverridden key falls back", theme.material_tint(PROP_BLOCK.MaterialPreset.METAL, "fill").is_equal_approx(Color.from_string("#808C99", Color.BLACK)))
check("unoverridden primitive falls back", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
DirAccess.remove_absolute(path)
func _test_invalid_color_fallback() -> void:
var path := "user://prop_theme_test_badcolor.json"
_write_user_theme('{"version":"1.0","material_tints":{"wood":{"fill":"not-a-color","outline":"also bad"}}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid fill falls back to PropBlock tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "fill").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD)))
check("invalid outline falls back to darkened tint", theme.material_tint(PROP_BLOCK.MaterialPreset.WOOD, "outline").is_equal_approx(PROP_BLOCK.tint_for(PROP_BLOCK.MaterialPreset.WOOD).darkened(0.55)))
DirAccess.remove_absolute(path)
func _test_invalid_number_fallback() -> void:
var path := "user://prop_theme_test_badnum.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":"wide"}}', path)
var theme: PropTheme = PROP_THEME.new()
theme.load_from(path)
check("invalid number falls back", is_equal_approx(theme.outline_width_default(), 2.0))
check("number clamp low", is_equal_approx(theme.number("outline_widths.default", 2.0, 1.0, 10.0), 2.0))
var path2 := "user://prop_theme_test_clamp.json"
_write_user_theme('{"version":"1.0","outline_widths":{"default":500}}', path2)
var theme2: PropTheme = PROP_THEME.new()
theme2.load_from(path2)
check("number clamp high", is_equal_approx(theme2.number("outline_widths.default", 2.0, -INF, 32.0), 32.0))
DirAccess.remove_absolute(path)
DirAccess.remove_absolute(path2)
func _test_physics_not_themeable() -> void:
check("mass lives in PropBlock", is_equal_approx(PROP_BLOCK.mass_for(PROP_BLOCK.MaterialPreset.WOOD), 3.0))
check("bounce lives in PropBlock", is_equal_approx(PROP_BLOCK.physics_material(PROP_BLOCK.MaterialPreset.RUBBER).bounce, 0.85))
var theme: PropTheme = PROP_THEME.new()
check("theme default config has no physics section", not theme.value("physics", null) is Dictionary)
var path := "user://prop_theme_test_physics.json"
_write_user_theme('{"version":"1.0","physics":{"wood":{"mass":99.0}}}', path)
theme.load_from(path)
check("attempted physics override has no effect on accessors", is_equal_approx(theme.outline_width_default(), 2.0) and theme.grid_size() == 15)
DirAccess.remove_absolute(path)
func _test_primitive_defaults() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("box size_x default", is_equal_approx(theme.primitive_default("box", "size_x"), 48.0))
check("ball radius default", is_equal_approx(theme.primitive_default("ball", "radius"), 24.0))
check("plank length default", is_equal_approx(theme.primitive_default("plank", "length"), 160.0))
check("triangle height default", is_equal_approx(theme.primitive_default("triangle", "height"), 48.0))
check("circle segments default", is_equal_approx(theme.primitive_default("circle", "segments"), 12.0))
check("unknown key uses caller fallback", is_equal_approx(theme.primitive_default("box", "radius", 7.5), 7.5))
check("unknown primitive uses caller fallback", is_equal_approx(theme.primitive_default("blob", "size_x", 3.0), 3.0))
func _test_preset_clamp() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("out-of-range preset clamps to NONE", theme.material_tint(999, "fill").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "fill")))
check("negative preset clamps to NONE", theme.material_tint(-3, "outline").is_equal_approx(theme.material_tint(PROP_BLOCK.MaterialPreset.NONE, "outline")))
func _test_ui_tokens() -> void:
var theme: PropTheme = PROP_THEME.new()
theme.load_from(PROP_THEME.DEFAULT_PATH)
check("font_body 18", theme.font_body() == 18)
check("font_header 24", theme.font_header() == 24)
check("font_menu 22", theme.font_menu() == 22)
check("font_menu_bar 22", theme.font_menu_bar() == 22)
check("font_mode 26", theme.font_mode() == 26)
check("font_status 18", theme.font_status() == 18)
check("font_prop_name 20", theme.font_prop_name() == 20)
check("font_context_menu 22", theme.font_context_menu() == 22)
check("button_min_height 36", theme.button_min_height() == 36)
check("row_min_height 36", theme.row_min_height() == 36)
check("mode_button_min_height 56", theme.mode_button_min_height() == 56)
check("text_color token", theme.text_color().is_equal_approx(Color.from_string("#F2F2F2", Color.WHITE)))
check("text_muted token", theme.text_muted_color().is_equal_approx(Color.from_string("#9A9A9A", Color.WHITE)))
check("border token", theme.border_color().is_equal_approx(Color.from_string("#FFFFFF33", Color.WHITE)))
check("accent token", theme.accent_color().is_equal_approx(Color.from_string("#4FA3FF", Color.WHITE)))
var path := "user://prop_theme_test_ui.json"
_write_user_theme('{"version":"1.0","ui":{"fonts":{"body":24},"colors":{"accent":"#FF8800"}}}', path)
var custom: PropTheme = PROP_THEME.new()
custom.load_from(path)
check("ui font override", custom.font_body() == 24)
check("unoverridden ui token falls back", custom.font_header() == 24)
check("ui accent override", custom.accent_color().is_equal_approx(Color.from_string("#FF8800", Color.WHITE)))
DirAccess.remove_absolute(path)
+1
View File
@@ -0,0 +1 @@
uid://bo7bwalcfi8x7
+80
View File
@@ -0,0 +1,80 @@
# Phase 9 Prop Editor - headless regression checks for the ThumbnailCache
# extension (prop_file_key / prop_file_png) and PNG round-trip.
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\tmp\stickman" --script "res://tests/test_prop_editor_thumb.gd"
extends SceneTree
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("\n=== PHASE 9 PROP THUMB TEST ===")
await _test_prop_file_keys()
await _test_builtin_prop_png_unchanged()
await _test_png_round_trip()
await _test_missing_file()
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 check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
print("FAIL: %s" % label)
func _test_prop_file_keys() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var path := "user://prop_thumb_key_test.prop"
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("{}")
f.close()
var key: String = cache.prop_file_key(path)
var mtime: int = FileAccess.get_modified_time(path)
check("prop_file_key format", key == "prop_thumb_key_test_%d" % mtime)
check("prop_file_png uses PROP_DIR", cache.prop_file_png(path) == "user://thumbnails/props/%s.png" % key)
check("prop_file_key missing file still formats", cache.prop_file_key("res://nope.prop") == "nope_0")
DirAccess.remove_absolute(path)
func _test_builtin_prop_png_unchanged() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("prop_png keeps version key", cache.prop_png("crate") == "user://thumbnails/props/crate_v%d.png" % THUMBNAIL_CACHE.PROP_VERSION)
check("stickman_png unchanged", cache.stickman_png("abc_123") == "user://thumbnails/stickmen/abc_123.png")
func _test_png_round_trip() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
var img := Image.create_empty(4, 4, false, Image.FORMAT_RGBA8)
img.fill(Color(1.0, 0.0, 0.0, 1.0))
var tex := ImageTexture.create_from_image(img)
var png_path := "user://thumbnails/props/test_roundtrip_v9.png"
var err: Error = cache.save_png(tex, png_path)
check("save_png succeeds", err == OK)
check("png exists on disk", FileAccess.file_exists(png_path))
cache.clear_memory()
var loaded: Texture2D = cache.load_png(png_path)
check("load_png round trip", loaded != null and loaded.get_size() == Vector2(4, 4))
check("save_png null texture rejected", cache.save_png(null, "user://thumbnails/props/null_v9.png") != OK)
DirAccess.remove_absolute(png_path)
func _test_missing_file() -> void:
var cache: ThumbnailCache = THUMBNAIL_CACHE.new()
check("load_png missing -> null", cache.load_png("user://thumbnails/props/definitely_missing.png") == null)
+1
View File
@@ -0,0 +1 @@
uid://bkmhs37lfr7em