235 lines
No EOL
10 KiB
GDScript
235 lines
No EOL
10 KiB
GDScript
extends SceneTree
|
|
## Editor-Tests (headless): Karte t_40d855d0 — UX-Fundament (Undo/Redo-Historie,
|
|
## Terrain-Höhen-Abfrage, Platzhalter-Meshes).
|
|
## Ausführen: godot --headless --path . --script res://tests/test_ux.gd
|
|
## Exit 0 = alles grün. Läuft OHNE UI (ActionHistory/SvmapDocument/MeshShapes).
|
|
## GUI-Funktionen (Ghost-Preview sichtbar, Auswahl-Färbung) werden im
|
|
## Desktop-Smoke auf Slys PC verifiziert (Screenshot), nicht hier.
|
|
|
|
var failures := 0
|
|
var checks := 0
|
|
|
|
|
|
func _init() -> void:
|
|
_run_tests()
|
|
if failures == 0:
|
|
print("ALL OK: %d checks" % checks)
|
|
quit(0)
|
|
else:
|
|
print("FAILED: %d/%d checks" % [failures, checks])
|
|
quit(1)
|
|
|
|
|
|
func _check(cond: bool, label: String) -> void:
|
|
checks += 1
|
|
if cond:
|
|
print(" ok: %s" % label)
|
|
else:
|
|
failures += 1
|
|
print(" FAIL: %s" % label)
|
|
|
|
|
|
func _obj(id: String, pos: Array, rot := 0.0) -> Dictionary:
|
|
return {"id": id, "pos": pos, "scale": 1.0, "rot_y_deg": float(rot)}
|
|
|
|
|
|
func _place(hist: ActionHistory, doc: Dictionary, id: String, pos: Array) -> void:
|
|
var before := hist.snapshot(["objects"])
|
|
(doc["objects"] as Array).append(_obj(id, pos))
|
|
hist.commit("Test: %s platzieren" % id, before, hist.snapshot(["objects"]))
|
|
|
|
|
|
func _delete_at(hist: ActionHistory, doc: Dictionary, idx: int) -> void:
|
|
var before := hist.snapshot(["objects"])
|
|
(doc["objects"] as Array).remove_at(idx)
|
|
hist.commit("Test: löschen", before, hist.snapshot(["objects"]))
|
|
|
|
|
|
func _move_obj(hist: ActionHistory, doc: Dictionary, idx: int, pos: Array) -> void:
|
|
var before := hist.snapshot(["objects"])
|
|
(doc["objects"] as Array)[idx]["pos"] = pos
|
|
hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:%d" % idx)
|
|
|
|
|
|
func _rotate_obj(hist: ActionHistory, doc: Dictionary, idx: int, deg: float) -> void:
|
|
var before := hist.snapshot(["objects"])
|
|
(doc["objects"] as Array)[idx]["rot_y_deg"] = float(deg)
|
|
hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:%d" % idx)
|
|
|
|
|
|
func _run_tests() -> void:
|
|
_test_undo_roundtrip()
|
|
_test_merge_and_truncation()
|
|
_test_alias_safety()
|
|
_test_history_cap_and_clear()
|
|
_test_terrain_height()
|
|
_test_mesh_shapes()
|
|
|
|
|
|
## Abnahme-Kriterium: Undo-Roundtrip (setzen → undo → redo) — und analog
|
|
## für löschen/verschieben/drehen.
|
|
func _test_undo_roundtrip() -> void:
|
|
print("Testgruppe: Undo/Redo-Roundtrip")
|
|
var doc := SvmapDocument.create_new("UX-Test", 1024.0, "mittag")
|
|
var hist := ActionHistory.new()
|
|
hist.begin(doc)
|
|
var objects: Array = doc["objects"]
|
|
_check(objects.is_empty(), "Start: Objekt-Liste leer")
|
|
|
|
_place(hist, doc, "crate_wood", [10.0, 0.0, 10.0])
|
|
_check(objects.size() == 1, "setzen: 1 Objekt")
|
|
_check(hist.can_undo() and not hist.can_redo(), "History: undo möglich, redo nicht")
|
|
|
|
var desc := hist.undo()
|
|
_check(objects.is_empty(), "undo: Objekt weg (setzen→undo)")
|
|
_check(hist.can_redo() and not hist.can_undo(), "nach undo: redo möglich, undo verbraucht")
|
|
_check(desc == "Test: crate_wood platzieren", "undo-Desc stimmt")
|
|
|
|
desc = hist.redo()
|
|
_check(objects.size() == 1 and str(objects[0]["id"]) == "crate_wood", "redo: Objekt identisch zurück (setzen→undo→redo)")
|
|
_check(desc == "Test: crate_wood platzieren", "redo-Desc stimmt")
|
|
_check(hist.can_undo() and not hist.can_redo(), "nach redo: Zustand konsistent")
|
|
|
|
_delete_at(hist, doc, 0)
|
|
_check(objects.is_empty(), "löschen: Liste leer")
|
|
hist.undo()
|
|
_check(objects.size() == 1, "löschen→undo: Objekt zurück")
|
|
hist.redo()
|
|
_check(objects.is_empty(), "löschen→undo→redo: wieder weg")
|
|
|
|
_place(hist, doc, "crate_metal", [0.0, 0.0, 0.0])
|
|
_check(objects.size() == 1, "weiterbearbeiten: Platzieren nach fertigem Roundtrip klappt")
|
|
_move_obj(hist, doc, 0, [5.0, 0.0, 7.5])
|
|
_check(float(objects[0]["pos"][0]) == 5.0, "verschieben: neue Position im Doc")
|
|
hist.undo()
|
|
_check(objects.size() == 1 and float(objects[0]["pos"][0]) == 0.0, "verschieben→undo: zurück auf Platzierungsposition")
|
|
hist.redo()
|
|
_check(float(objects[0]["pos"][0]) == 5.0 and float(objects[0]["pos"][2]) == 7.5, "verschieben→undo→redo: Position wiederhergestellt")
|
|
|
|
_rotate_obj(hist, doc, 0, 90.0)
|
|
hist.undo()
|
|
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "drehen→undo: 0°")
|
|
hist.redo()
|
|
_check(absf(float(objects[0]["rot_y_deg"]) - 90.0) < 0.0001, "drehen→undo→redo: 90°")
|
|
|
|
|
|
## Mausrad/leichtes Drehen und Verschieben in Ticks dürfen die History NICHT
|
|
## fluten — gleicher merge_tag pro Objekt = EIN History-Eintrag.
|
|
func _test_merge_and_truncation() -> void:
|
|
print("Testgruppe: Merge-Tags (Mausrad-Ticks → 1 Schritt) + Redo-Ast")
|
|
var doc := SvmapDocument.create_new("Merge-Test", 1024.0, "mittag")
|
|
var hist := ActionHistory.new()
|
|
hist.begin(doc)
|
|
var objects: Array = doc["objects"]
|
|
_place(hist, doc, "watchtower_wood", [0.0, 0.0, 0.0])
|
|
var entries_after_place := hist.entries.size()
|
|
for i in 8:
|
|
var before := hist.snapshot(["objects"])
|
|
objects[0]["rot_y_deg"] = float((i + 1) * 15)
|
|
hist.commit("Objekt gedreht", before, hist.snapshot(["objects"]), "rot:0")
|
|
_check(hist.entries.size() == entries_after_place + 1, "8 Dreh-Ticks → EIN zusätzlicher History-Eintrag (geflutet wären 8)")
|
|
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Endposition 120° im Doc")
|
|
|
|
# innerster Zustand nach Merge-Undo = Zustand VOR der ersten Drehung (0°)
|
|
hist.undo()
|
|
_check(absf(float(objects[0]["rot_y_deg"]) - 0.0) < 0.0001, "Merge-Undo springt auf 0° (nicht auf Zwischenstufe 105°)")
|
|
hist.redo()
|
|
_check(absf(float(objects[0]["rot_y_deg"]) - 120.0) < 0.0001, "Merge-Redo wieder 120°")
|
|
|
|
# Neuer Schritt verwirft den Redo-Zweig
|
|
_place(hist, doc, "bush_small", [1.0, 0.0, 1.0])
|
|
_check(not hist.can_redo(), "neuer Schritt: Redo-Zweig verworfen")
|
|
hist.undo()
|
|
_check(objects.size() == 1, "undo nach Zweig: nur watchtower bleibt")
|
|
|
|
|
|
## Snapshot VOR Mutation darf nach Mutation unverändert bleiben (sonst
|
|
## „rettet“ undo versehentlich eine Mutation — Alias-Falle bei Godot-Dicts).
|
|
func _test_alias_safety() -> void:
|
|
print("Testgruppe: Alias-Sicherheit der Snapshots")
|
|
var doc := SvmapDocument.create_new("Alias", 1024.0, "mittag")
|
|
var hist := ActionHistory.new()
|
|
hist.begin(doc)
|
|
var before := hist.snapshot(["objects"])
|
|
(doc["objects"] as Array).append(_obj("crate_metal", [1.0, 0.0, 2.0]))
|
|
hist.commit("alias-test", before, hist.snapshot(["objects"]))
|
|
var stored_before: Array = hist.entries[0]["before"]["objects"]
|
|
_check(stored_before.is_empty(), "before-Snapshot: leer")
|
|
|
|
var stored_after: Array = hist.entries[0]["after"]["objects"]
|
|
stored_after.clear()
|
|
_check((doc["objects"] as Array).size() == 1, "Clear auf gespeichertem Snapshot ändert Doc nicht (tiefe Kopie)")
|
|
|
|
var doc_objects: Array = doc["objects"]
|
|
doc_objects.append({"id": "fence_metal", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
|
|
hist.undo()
|
|
_check((doc["objects"] as Array).is_empty(), "undo trotz Doc-Mutation: sauber auf before (leer)")
|
|
hist.redo()
|
|
_check((doc["objects"] as Array).size() == 1, "redo: Snapshot-Zustand (1 Objekt), nicht der mutierte")
|
|
|
|
|
|
func _test_history_cap_and_clear() -> void:
|
|
print("Testgruppe: Kapazität + begin() setzt zurück")
|
|
var doc := SvmapDocument.create_new("Cap", 1024.0, "mittag")
|
|
var hist := ActionHistory.new()
|
|
hist.begin(doc)
|
|
var objects: Array = doc["objects"]
|
|
for i in ActionHistory.MAX_STEPS + 20:
|
|
_place(hist, doc, "crate_wood", [float(i), 0.0, 0.0])
|
|
_check(hist.entries.size() == ActionHistory.MAX_STEPS, "Cap: History hält sich an MAX_STEPS")
|
|
_check(objects.size() == ActionHistory.MAX_STEPS + 20, "Doc fuellt sich voll — Cap betrifft nur History-Tiefe")
|
|
hist.undo()
|
|
_check(objects.size() == ActionHistory.MAX_STEPS + 19, "auch nach Cap-Kippen funktioniert undo")
|
|
|
|
var doc2 := SvmapDocument.create_new("Frisch", 1024.0, "nacht")
|
|
hist.begin(doc2)
|
|
_check(not hist.can_undo() and not hist.can_redo(), "begin() mit neuer Map: History leer")
|
|
_check(hist.doc == doc2, "begin() hängt an der neuen Map-Referenz")
|
|
|
|
|
|
## Flache neue Map → Terrain-Höhe überall = min_height. Synthetischer Gradient →
|
|
## bilinear korrekt (roher uint16-Wert → Meter).
|
|
func _test_terrain_height() -> void:
|
|
print("Testgruppe: Terrain-Höhen-Abfrage")
|
|
var doc := SvmapDocument.create_new("Flach", 1024.0, "mittag")
|
|
_check(absf(SvmapDocument.terrain_height_at(doc, 0.0, 0.0) - 0.0) < 0.001, "flache Map: Höhe 0 m")
|
|
_check(absf(SvmapDocument.terrain_height_at(doc, 500.0, -500.0) - 0.0) < 0.001, "flache Map: auch am Rand 0 m")
|
|
|
|
# Synthetischer Gradient: res=17, size=160 → 10 m Raster; Raw-Wert = Zeile*655 (ROH skaliert)
|
|
var res := 17
|
|
var bytes := PackedByteArray()
|
|
bytes.resize(res * res * 2)
|
|
var i := 0
|
|
while i < res * res:
|
|
var row := i / res
|
|
var raw := int(round(float(row) / float(res - 1) * 65535.0))
|
|
bytes[i * 2] = raw & 0xFF
|
|
bytes[i * 2 + 1] = (raw >> 8) & 0xFF
|
|
i += 1
|
|
var slope := {
|
|
"type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0,
|
|
"min_height_m": 0.0, "max_height_m": 100.0, "resolution": res,
|
|
"heightmap_r16_b64": Marshalls.raw_to_base64(bytes),
|
|
}
|
|
var doc2 := {"terrain": slope}
|
|
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 0.0) - 0.0) < 0.5, "Gradient: Nordkante ≈ 0 m")
|
|
var mid := SvmapDocument.terrain_height_at(doc2, 0.0, 80.0)
|
|
_check(absf(mid - 50.0) < 1.0, "Gradient: Mitte ≈ 50 m (ist %.1f)" % mid)
|
|
_check(absf(SvmapDocument.terrain_height_at(doc2, 0.0, 160.0) - 100.0) < 0.5, "Gradient: Südkante ≈ 100 m")
|
|
_check(absf(SvmapDocument.terrain_height_at(doc2, 5000.0, -900.0) - 0.0) < 0.6, "Außerhalb-Query wird geclampt (keine Abstürze)")
|
|
|
|
|
|
func _test_mesh_shapes() -> void:
|
|
print("Testgruppe: Platzhalter-Meshes (Bibliothek/Kollider)")
|
|
var b := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 1.0)
|
|
_check(b is BoxMesh, "box → BoxMesh")
|
|
_check((b as BoxMesh).size == Vector3(2, 4, 6), "box: Größe 1:1 aus dem Kollider")
|
|
var scaled := MeshShapes.make_placeholder("box", {"size": [2.0, 4.0, 6.0]}, 2.0)
|
|
_check((scaled as BoxMesh).size.is_equal_approx(Vector3(4, 8, 12)), "box: scale=2 verdoppelt alle Achsen")
|
|
var sp := MeshShapes.make_placeholder("sphere", {"radius": 1.5}, 1.0)
|
|
_check(sp is SphereMesh and absf((sp as SphereMesh).radius - 1.5) < 0.001, "sphere → SphereMesh mit Radius")
|
|
var cyl := MeshShapes.make_placeholder("cylinder", {"radius": 0.4, "height": 12.0}, 1.0)
|
|
_check(cyl is CylinderMesh and absf((cyl as CylinderMesh).height - 12.0) < 0.001, "cylinder → CylinderMesh mit Höhe")
|
|
_check(absf(MeshShapes.half_height(b) - 2.0) < 0.001, "half_height(box 4 m) = 2 m — Fußpunkt-Konvention")
|
|
_check(absf(MeshShapes.half_height(sp) - 1.5) < 0.001, "half_height(sphere) = radius")
|
|
_check(absf(MeshShapes.half_height(cyl) - 6.0) < 0.001, "half_height(cylinder 12 m) = 6 m") |