Add headless regression tests for Prop Editor functionality

- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality.
- Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management.
- Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens.
- Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
This commit is contained in:
2026-09-19 21:26:03 -04:00
parent db4ff8d939
commit b66ded5f60
34 changed files with 7021 additions and 2 deletions
+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)