scopedvr-map-editor/tests/test_spawns.gd

314 lines
No EOL
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_c4162c5d — Spawn-Zonen-Werkzeug.
## Ausführen: godot --headless --path . --script res://tests/test_spawns.gd
## Exit 0 = alles grün. Läuft OHNE UI (SpawnOps/SvmapIO/ActionHistory).
## GUI-Feinheiten (Kreis-Optik, Shake, Ghost) verifiziert der Desktop-Smoke
## (--spawns-demo auf Slys PC), nicht hier.
var failures := 0
var checks := 0
func _init() -> void:
_cleanup()
_run_tests()
_cleanup()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _cleanup() -> void:
for path in ["user://test_spawns.svmap", "user://test_spawns2.svmap"]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _manifest() -> Dictionary:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH))
return parsed if parsed is Dictionary else {}
func _commit_spawn(hist: ActionHistory, doc: Dictionary, desc: String) -> void:
hist.commit(desc, SpawnOps.snapshot_blocks(hist, doc), SpawnOps.snapshot_blocks(hist, doc))
func _run_tests() -> void:
_test_defaults_and_ids()
_test_place_undo_roundtrip()
_test_radius_change_and_merge()
_test_delete_and_undo()
_test_move_and_undo()
_test_live_validation_status()
_test_ready_report()
_test_export_reimport_identical()
_test_zone_on_terrain()
func _test_defaults_and_ids() -> void:
print("Testgruppe: Default-Zonen + ID-Vergabe")
var doc := SvmapDocument.create_new("ID-Test", 512.0, "mittag")
_check(SpawnOps.count_points(doc, 0) == 4, "Team Blau: 4 Default-Zonen")
_check(SpawnOps.count_points(doc, 1) == 4, "Team Rot: 4 Default-Zonen")
_check(SpawnOps.next_id(doc, 0) == "A5", "nächste blaue ID = A5 (A1–A4 belegt)")
_check(SpawnOps.next_id(doc, 1) == "B5", "nächste rote ID = B5 (B1–B4 belegt)")
_check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS,
"Radius ohne Eintrag = Default (%d m)" % int(SpawnOps.DEFAULT_ZONE_RADIUS))
## Zone setzen → undo → redo: spawn_zones UND design_notes kommen beide zurück.
func _test_place_undo_roundtrip() -> void:
print("Testgruppe: Zone setzen/Undo/Redo")
var doc := SvmapDocument.create_new("Place-Test", 512.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
var point := SpawnOps.add_zone(doc, 0, -30.0, 40.0)
SpawnOps.set_radius(doc, str(point["id"]), 9.0)
hist.commit("Spawn-Zone %s gesetzt" % point["id"], before, SpawnOps.snapshot_blocks(hist, doc))
_check(SpawnOps.count_points(doc, 0) == 5, "setzen: 5 blaue Zonen")
_check(absf(SpawnOps.zone_radius(doc, str(point["id"])) - 9.0) < 0.001, "Radius 9 m im Doc (design_notes)")
var pid: String = str(point["id"])
_check(pid == "A5", "automatische ID A5")
hist.undo()
_check(SpawnOps.count_points(doc, 0) == 4, "undo: Zone weg")
var notes = doc.get("design_notes", {})
var radii = (notes as Dictionary).get("spawn_radius_m", {}) if notes is Dictionary else {}
_check(not (radii is Dictionary and (radii as Dictionary).has(pid)),
"undo: Radius-Eintrag auch weg (kein Geist in design_notes)")
hist.redo()
_check(SpawnOps.count_points(doc, 0) == 5, "redo: Zone zurück")
_check(absf(SpawnOps.zone_radius(doc, pid) - 9.0) < 0.001, "redo: Radius 9 m wieder da")
## Radius-Slider-Ticks dürfen die History NICHT fluten — EIN Eintrag pro Zone.
func _test_radius_change_and_merge() -> void:
print("Testgruppe: Radius ändern + Merge-Tags")
var doc := SvmapDocument.create_new("Radius-Test", 512.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var entries_before := hist.entries.size()
for m in [4.0, 5.0, 6.0]:
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
SpawnOps.set_radius(doc, "A1", float(m))
hist.commit("Radius von A1: %d m" % int(m), before, SpawnOps.snapshot_blocks(hist, doc), "radius:A1")
_check(hist.entries.size() == entries_before + 1, "3 Slider-Ticks → EIN History-Eintrag")
_check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Endradius 6 m im Doc")
hist.undo()
_check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS, "Merge-Undo: zurück auf Default")
hist.redo()
_check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Merge-Redo: wieder 6 m")
## Zone löschen → undo: Punkt MIT Position und Radius-Eintrag zurück.
func _test_delete_and_undo() -> void:
print("Testgruppe: Zone löschen/Undo")
var doc := SvmapDocument.create_new("Delete-Test", 512.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
SpawnOps.set_radius(doc, "A2", 12.0)
var pos_before: Array = SpawnOps.points(doc, 0)[1]["pos"]
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
SpawnOps.remove_point(doc, 0, "A2")
hist.commit("A2 löschen", before, SpawnOps.snapshot_blocks(hist, doc))
_check(SpawnOps.count_points(doc, 0) == 3, "löschen: 3 blaue Zonen")
_check(SpawnOps.next_id(doc, 0) == "A2", "freigewordene ID wird wieder vergeben")
hist.undo()
_check(SpawnOps.count_points(doc, 0) == 4, "undo: 4 blaue Zonen")
var pos_after: Array = SpawnOps.points(doc, 0)[1]["pos"]
_check(float(pos_after[0]) == float(pos_before[0]) and float(pos_after[2]) == float(pos_before[2]),
"undo: Position von A2 identisch zurück")
_check(absf(SpawnOps.zone_radius(doc, "A2") - 12.0) < 0.001, "undo: Radius 12 m zurück")
## Zone verschieben → undo → redo (Position und Terrain-Höhe).
func _test_move_and_undo() -> void:
print("Testgruppe: Zone verschieben/Undo")
var doc := SvmapDocument.create_new("Move-Test", 512.0, "mittag")
var hist := ActionHistory.new()
hist.begin(doc)
var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc)
SpawnOps.move_point(doc, 1, "B1", 111.0, -77.0)
hist.commit("B1 verschoben", before, SpawnOps.snapshot_blocks(hist, doc))
var moved: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"]
_check(absf(float(moved[0]) - 111.0) < 0.001 and absf(float(moved[2]) + 77.0) < 0.001,
"verschieben: neue X/Z im Doc")
hist.undo()
var back: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"]
_check(absf(float(back[0]) - 204.8) < 1.0, "undo: B1 wieder an der Startkante (x = 0.8 × 256 m)")
hist.redo()
_check(absf(float(moved[0]) - 111.0) < 0.001, "redo: Verschiebung wieder da")
## Live-Validierung spiegelt die Loader-Regeln: Teams < 4 → ❌, unbekannte
## Objekt-ID → ❌, sonst ✅. Genau diese Meldungen sieht Sly VOR dem Export.
func _test_live_validation_status() -> void:
print("Testgruppe: Live-Validierungs-Status")
var doc := SvmapDocument.create_new("Live-Test", 512.0, "mittag")
var manifest := _manifest()
var text := SpawnOps.live_status(doc, manifest)
_check(text.contains("✅ Spawns Team Blau: 4"), "Live: ✅ Blau 4 (Default)")
_check(text.contains("✅ Spawns Team Rot: 4"), "Live: ✅ Rot 4 (Default)")
_check(text.contains("✅ Objekt-IDs"), "Live: ✅ Objekt-IDs ohne Objekte (optional)")
# 2 rote Zonen löschen (immer den LETZTEN Punkt — next_id liefert nur freie,
# nicht existierende IDs und wäre hier die falsche Wahl) → ❌ Team Rot: 2
while SpawnOps.count_points(doc, 1) > 2:
var pts := SpawnOps.points(doc, 1)
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
text = SpawnOps.live_status(doc, manifest)
_check(text.contains("❌ Spawns Team Rot: 2"), "Live: ❌ Rot 2 nach Löschaktionen")
var lines := SpawnOps.validate_lines(doc, manifest)
var team1_hint := ""
for line in lines:
if str(line["text"]).contains("Team Rot") and not bool(line["ok"]):
team1_hint = str(line["hint"])
_check(team1_hint.contains("Spawns"), "fehlende Zone → hilfreicher Hinweis (wo Zonen malen)")
# Objekt mit unbekannter ID → ❌ Objekt-IDs
(doc["objects"] as Array).append({"id": "nicht_in_bibliothek", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
text = SpawnOps.live_status(doc, manifest)
_check(text.contains("❌ Objekt-IDs"), "Live: ❌ Objekt-IDs bei unbekannter ID")
_check(text.contains("1 von 1 unbekannt"), "Live: Anzahl unbekannter Objekte genannt")
## "Karte prüfen": neue Map = bereit; kaputte Map = Klartext-Probleme.
func _test_ready_report() -> void:
print("Testgruppe: Map-Fertig-Check (ready_report)")
var doc := SvmapDocument.create_new("Check-Test", 512.0, "abend")
(doc["info"] as Dictionary)["author"] = "Sly"
var report := SpawnOps.ready_report(doc, _manifest())
_check(bool(report["ok"]), "frische Map (4+4 Spawns): bereit zum Export")
var all_ok := true
for line in report["lines"]:
if not bool(line["ok"]):
all_ok = false
_check(all_ok, "alle Check-Zeilen ✅ bei frischer Map")
var has_poly := false
for line in report["lines"]:
if str(line["text"]).contains("Poly-Budget"):
has_poly = true
_check(has_poly, "Poly-Budget-Zeile im Klartext-Report")
# Kaputt: Name leer + Spawn unter Limit + unbekanntes Objekt.
(doc["info"] as Dictionary)["name"] = " "
while SpawnOps.count_points(doc, 1) > 1:
var pts := SpawnOps.points(doc, 1)
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
(doc["objects"] as Array).append({"id": "unicorn_mesh", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0})
report = SpawnOps.ready_report(doc, _manifest())
_check(not bool(report["ok"]), "kaputte Map: NICHT bereit")
var found := []
for line in report["lines"]:
found.append(str(line["text"]))
_check(_any_contains(found, "Map-Name"), "Check nennt fehlenden Map-Namen")
_check(_any_contains(found, "V11"), "Check nennt V11 (Spawn-Limit) aus dem Loader-Kern")
_check(_any_contains(found, "V7"), "Check nennt V7 (unbekannte Objekt-ID) aus dem Loader-Kern")
func _any_contains(texts: Array, needle: String) -> bool:
for t in texts:
if t.contains(needle):
return true
return false
## Editor-Export → Loader-Re-Import = identisch. Alle Änderungen (Zonen +/−,
## Radien, Objekte) überleben Speichern/Laden; Prüfsumme validiert.
func _test_export_reimport_identical() -> void:
print("Testgruppe: Export + Loader-Re-Import = identisch")
var doc := SvmapDocument.create_new("Export-Test", 512.0, "regen")
(doc["info"] as Dictionary)["author"] = "Sly"
# Objekte platzieren (bekannte IDs aus der Bibliothek).
(doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 15.0})
(doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [-40.0, 0.0, -20.0], "scale": 1.0, "rot_y_deg": 0.0})
# Spawn-Zonen umbauen: 2 rote löschen, 2 rote NEU setzen (IDs A5/B5,B6),
# Radien setzen. Beide Teams bleiben ≥ 4 → Export gültig.
while SpawnOps.count_points(doc, 1) > 2:
var pts := SpawnOps.points(doc, 1)
SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"]))
var red1 := SpawnOps.add_zone(doc, 1, -222.0, 11.0)
var red2 := SpawnOps.add_zone(doc, 1, -233.0, -11.0)
var point := SpawnOps.add_zone(doc, 0, 33.0, -44.0)
SpawnOps.set_radius(doc, str(point["id"]), 14.0)
SpawnOps.set_radius(doc, "B1", 3.0)
SpawnOps.set_radius(doc, str(red1["id"]), 8.0)
_check(SpawnOps.count_points(doc, 0) == 5 and SpawnOps.count_points(doc, 1) == 4,
"Testdokument: 5 blaue / 4 rote Zonen (beide Teams über dem V11-Limit)")
# DER Export: derselbe Code wie beim Speichern (SvmapIO.save_to_file → validate + sign).
var save := SvmapIO.save_to_file("user://test_spawns.svmap", doc)
_check(save["ok"], "save_to_file ok: %s" % str(save["errors"]))
var load := SvmapIO.load_from_file("user://test_spawns.svmap")
_check(load["ok"], "load_from_file ok (Prüfsumme stimmt)")
var back: Dictionary = load["data"]
_check((back["spawn_zones"]["0"]["points"] as Array).size() == 5, "Re-Import: 5 blaue Zonen")
_check((back["spawn_zones"]["1"]["points"] as Array).size() == 4, "Re-Import: 4 rote Zonen")
var back_points: Array = back["spawn_zones"]["0"]["points"]
var found_new := false
for p in back_points:
if str(p["id"]) == str(point["id"]):
found_new = true
_check(found_new, "Re-Import: neue Zone %s ist drin" % point["id"])
_check(_radius_from(back, str(point["id"])) == 14.0, "Re-Import: Radius 14 m überlebt (design_notes)")
_check(_radius_from(back, "B1") == 3.0, "Re-Import: Radius B1 = 3 m überlebt")
_check(_radius_from(back, str(red1["id"])) == 8.0, "Re-Import: Radius der NEUEN roten Zone = 8 m")
_check((back["objects"] as Array).size() == 2, "Re-Import: beide Objekte da")
# Roundtrip: speichern → laden → speichern → byte-identisch (Kanonisierung).
var save2 := SvmapIO.save_to_file("user://test_spawns2.svmap", back)
_check(save2["ok"], "Re-Save ok")
var text1 := FileAccess.get_file_as_string("user://test_spawns.svmap")
var text2 := FileAccess.get_file_as_string("user://test_spawns2.svmap")
_check(text1 == text2, "speichern→laden→speichern = byte-identisch")
func _radius_from(data: Dictionary, pid: String) -> float:
var notes = data.get("design_notes", {})
if notes is Dictionary and (notes as Dictionary).get("spawn_radius_m", {}) is Dictionary:
return float((notes["spawn_radius_m"] as Dictionary).get(pid, -1.0))
return -1.0
## Mit Höhenfeld: neue Zone sitzt AUF dem Boden (y = Terrain-Höhe am Punkt).
func _test_zone_on_terrain() -> void:
print("Testgruppe: Zone auf Terrain-Höhe")
# Gefälltes Terrain: Nordkante 0 m, Südkante 100 m (res 17, size 160).
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 terrain := {
"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 doc := SvmapDocument.create_new("Hügel-Test", 160.0, "abend")
doc["terrain"] = terrain
var point := SpawnOps.add_zone(doc, 1, 0.0, 80.0)
_check(absf(float(point["pos"][1]) - 100.0) < 1.0, "Zone auf Südkante: y ≈ 100 m (auf dem Boden)")
SpawnOps.move_point(doc, 1, str(point["id"]), 0.0, -80.0)
var moved_pos: Variant = null
for p in SpawnOps.points(doc, 1):
if str(p["id"]) == str(point["id"]):
moved_pos = p["pos"]
_check(moved_pos != null and absf(float(moved_pos[1]) - 0.0) < 1.0, "verschieben auf Nordkante: y ≈ 0 m")