diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 7da4925..61be914 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -32,6 +32,10 @@ var view_builder # script-erweitert, Variant var placing := false var place_oid := "" var place_rot_deg := 0.0 +# U5 (t_5e3e97c9): Shift beim Setz-Klick = Serienmodus — weiter setzen wie +# bisher (Ghost bleibt an, jeder Klick setzt). Ohne Shift wird EIN Objekt +# gesetzt und das Werkzeug beendet sich selbst; das neue Objekt ist ausgewählt. +var _place_series := false var _ghost: Node3D # Ghost-ROOT: Position = Objekt-Fußpunkt — # identisch zur platzierten Marker-Position # (Karte t_4092e3a0: Ghost == Platzierung). @@ -195,6 +199,7 @@ func start_placement(oid: String) -> void: place_oid = oid placing = true place_rot_deg = 0.0 + _place_series = false # U5: jeder frische Start startet im Einmal-Modus _ghost_active = false refresh_terrain_cache() # Cursor trifft das GELÄNDE — Cache muss frisch sein _ensure_ghost() @@ -211,11 +216,30 @@ func cancel_placement() -> void: _ghost.visible = false placing = false place_oid = "" + _place_series = false _ghost_active = false main.call("_highlight_library", "") main.call("_base_tool_status") +## U5 (t_5e3e97c9): Ein Setz-Klick beendet das Platzier-Werkzeug und wählt +## GENAU das neue Objekt aus (Baum-Markierung + Inspektor folgen über +## select_object). Ghost aus, Kachel-Hervorhebung im Assets-Panel aus +## (_highlight_library("")), das Objekt-Werkzeug bleibt das aktive Werkzeug. +## from_tree=true: der Baum wird bereits von _apply_selection_view markiert — +## kein zweites Auswahl-Ereignis (Auswahl-Stabilität, Karten-Test). +## Aufruf VOR der Klartext-Statusmeldung: die letzte Meldung gewinnt. +func finish_placement_and_select(new_index: int) -> void: + if _ghost != null and is_instance_valid(_ghost): + _ghost.visible = false + placing = false + place_oid = "" + _place_series = false + _ghost_active = false + main.call("_highlight_library", "") + select_object(new_index, true) + + func _ensure_ghost() -> void: if _ghost != null and is_instance_valid(_ghost): return @@ -285,7 +309,7 @@ func _ghost_update(viewport_pos: Vector2) -> void: _ghost.visible = true -func _place_object(viewport_pos: Vector2) -> void: +func _place_object(viewport_pos: Vector2, mb_shift_held := false) -> void: var p: Variant = _ground_point(viewport_pos) if p == null: main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) @@ -339,15 +363,25 @@ func _place_object(viewport_pos: Vector2) -> void: main.history.commit("%s platzieren" % main.object_label(place_oid), before, main.history.snapshot(["objects"])) main.call("_after_structure_change") - if on_axis: - main.call("_set_status", "%s platziert — liegt auf der Symmetrie-Mitte und wird NICHT gedoppelt. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad)." % main.object_label(place_oid)) - elif twin_index >= 0: - main.call("_set_status", "%s platziert — der Zwilling auf der anderen Seite ist automatisch mitgesetzt (Auswahl zeigt ihn schwächer markiert). Strg+Z nimmt BEIDE zurück." % main.object_label(place_oid)) + # U5 (t_5e3e97c9): Shift gehalten = Serienmodus (weiter setzen wie bisher, + # Ghost bleibt an) — sonst beendet das Werkzeug sich selbst und DAS NEUE + # Objekt ist ausgewählt (Baum markiert, Inspektor gefüllt). + if mb_shift_held: + _place_series = true + select_object(new_index, true) + if twin_index >= 0: + main.call("_set_status", "Serienmodus (Shift) — ESC beendet. %s platziert, Zwilling jeweils mitgesetzt; Strg+Z nimmt BEIDE je Schritt zurück. Weitere Klicks setzen erneut." % main.object_label(place_oid)) + else: + main.call("_set_status", "Serienmodus (Shift) — ESC beendet. Weitere Klicks setzen erneut; Strg+Z nimmt je Schritt zurück.") else: - main.call("_set_status", "%s platziert. Tipp: anklicken zum Verschieben (Ziehen) und Drehen (R oder Mausrad) — Entf löscht es." % main.object_label(place_oid)) - # Platzier-Modus bleibt aktiv (Mehrfach-Platzieren ohne erneutes Klicken), - # Auswahl auf das frische Objekt legen? Nein — Ghost bleibt Vorrang; ESC beendet. - main.call("_mark_tree_selection", new_index) + var placed_oid := place_oid # U5: finish leert place_oid — Label vorher sichern + finish_placement_and_select(new_index) + if twin_index >= 0: + main.call("_set_status", "%s gesetzt und ausgewählt — der Spiegel-Zwilling wurde mitgesetzt; Strg+Z nimmt BEIDE zurück. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht." % main.object_label(placed_oid)) + elif on_axis: + main.call("_set_status", "%s gesetzt und ausgewählt — liegt auf der Symmetrie-Mitte und wird NICHT gedoppelt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht." % main.object_label(placed_oid)) + else: + main.call("_set_status", "%s gesetzt und ausgewählt. Ziehen verschiebt, R oder Mausrad dreht, Entf löscht, Strg+Z nimmt es zurück." % main.object_label(placed_oid)) # --------------------------------------------------------------- Auswahl ---- @@ -1096,7 +1130,7 @@ func _unhandled_input(event: InputEvent) -> void: func _on_left_press(mb: InputEventMouseButton) -> void: if placing: - _place_object(mb.position) + _place_object(mb.position, mb.shift_pressed) # U5: Shift = Serienmodus return _press_pos = mb.position var hit := _pick_object(mb.position) diff --git a/tests/probe_u5_place_once_render.gd b/tests/probe_u5_place_once_render.gd new file mode 100644 index 0000000..71f3a0b --- /dev/null +++ b/tests/probe_u5_place_once_render.gd @@ -0,0 +1,142 @@ +extends SceneTree +## Beweis-Probe (Karte t_5e3e97c9, WEGWERF — nicht Teil der Suite): +## U5 Platzieren: Bildfolge VORHER (Ghost in der Hand) → GESETZT (Marker da, +## Werkzeug aus) → AUSGEWÄHLT (Auswahl-Markierung + Inspektor-Tab sichtbar). +## Der Platzier-Pfad ist der ECHTE: interaction._place_object (gleicher Pfad +## wie ein Linksklick, Karten-Regel: kein simuliertes Umfeld). +## Aufruf: xvfb-run -a godot4 --path . --rendering-driver opengl3 \ +## -s res://tests/probe_u5_place_once_render.gd + +func _initialize() -> void: + _run.call_deferred() + + +func _shot(pfad: String) -> void: + await process_frame + await RenderingServer.frame_post_draw + var img := root.get_viewport().get_texture().get_image() + img.save_png(pfad) + print("Bild: %s (%dx%d)" % [pfad, img.get_width(), img.get_height()]) + + +## Rechts-Tabs: den Tab mit „Inspektor“ nach vorn holen (Fallback „Baum“), +## damit die Auswahl im Beweis-Bild sichtbar wird. +func _show_selection_tab(main: Node) -> String: + var stack: Array = [main] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is TabContainer: + var tabs := n as TabContainer + for i in tabs.get_tab_count(): + var titel := tabs.get_tab_title(i) + if titel.contains("Auswahl") or titel.contains("Baum"): + tabs.current_tab = i + return titel + stack.append_array(n.get_children()) + return "" + + +## „Modelle fehlen“-AcceptDialog schließen (kommt erst nach 2 Frames von +## utils/modelle_status.gd — mehrfach probieren, mit Wartezeit dazwischen). +func _dismiss_dialogs() -> void: + for i in 6: + await process_frame + var stack: Array = [root] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is AcceptDialog: + (n as AcceptDialog).hide() + n.queue_free() + stack.append_array(n.get_children()) + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + var main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + # „Modelle fehlen“-Hinweis schließen (Beweis-Bilder sollen die Karte zeigen). + await _dismiss_dialogs() + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_size(Vector2i(2328, 1310)) + await process_frame + DisplayServer.window_set_size(Vector2i(2328, 1310)) + var interaction = main.get("interaction") + var oid := "" + var lib: Dictionary = main.manifest.get("objects", {}) + for key in lib: + oid = str(key) + break + if oid == "": + print("PROBE_U5_FAIL: kein Manifest-Objekt") + quit(1) + return + main.call("_set_tool_mode", "objects") + interaction.call("start_placement", oid) + await process_frame + await process_frame + var cam = main.get("camera") + if cam != null: + cam.set("distance", 22.0) + cam.call("focus_on", Vector3(0.0, 0.0, 0.0)) + await process_frame + await process_frame + # Cursor übers Karte-Zentrum → Ghost sichtbar parken (echter Ghost-Pfad). + var vp: SubViewport = null + var stack: Array = [main] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is SubViewportContainer: + vp = n.get_child(0) as SubViewport + break + stack.append_array(n.get_children()) + if vp == null: + print("PROBE_U5_FAIL: kein SubViewport") + quit(1) + return + var mm := InputEventMouseMotion.new() + mm.position = (vp.size as Vector2) * 0.5 + mm.global_position = mm.position + vp.push_input(mm) + await process_frame + await process_frame + var dir := "/tmp/u5_beweise" + DirAccess.make_dir_recursive_absolute(dir) + await _shot(dir + "/u5_1_vorher_ghost.png") + print("PRUEF vorher: placing=%s objects=%s" % [ + str(bool(interaction.get("placing"))), + str((main.doc["objects"] as Array).size())]) + # ECHTER Platzier-Pfad (wie Linksklick, ohne Shift → Einmal-Modus). + interaction.call("_place_object", mm.position) + await process_frame + await process_frame + await _shot(dir + "/u5_2_gesetzt.png") + var placed := (main.doc["objects"] as Array).size() + var builder = main.get("view_builder") + var sel_mat: StandardMaterial3D = builder.get("_select_mat") + var marker = null + if placed > 0: + marker = builder.object_markers[placed - 1].get_child(0) + var hat_auswahl: bool = marker != null and marker.material_override == sel_mat + var insp = main.get("inspector") + var head_txt := "" + if insp != null and insp.get("_head") != null: + head_txt = str(insp.get("_head").text) + print("PRUEF gesetzt: placing=%s objects=%s selected_index=%s marker_auswahl=%s inspektor='%s'" % [ + str(bool(interaction.get("placing"))), str(placed), + str(interaction.get("selected_index")), str(hat_auswahl), head_txt]) + print("PRUEF status='%s'" % str(main.status_label.text)) + # Bild 3: Auswahl im UI sichtbar — Inspektor-Tab nach vorn. + var tab := _show_selection_tab(main) + await process_frame + await process_frame + print("PRUEF tab='%s'" % tab) + await _shot(dir + "/u5_3_ausgewaehlt_inspektor.png") + var ok := not bool(interaction.get("placing")) \ + and placed == 1 \ + and int(interaction.get("selected_index")) == 0 \ + and hat_auswahl \ + and str(main.status_label.text).contains("ausgewählt") + print("PROBE_U5_%s" % ("OK" if ok else "FAIL")) + quit(0 if ok else 1) \ No newline at end of file diff --git a/tests/probe_u5_place_once_render.gd.uid b/tests/probe_u5_place_once_render.gd.uid new file mode 100644 index 0000000..e5e6260 --- /dev/null +++ b/tests/probe_u5_place_once_render.gd.uid @@ -0,0 +1 @@ +uid://c3cmwfhdm7vro diff --git a/tests/test_place_once.gd b/tests/test_place_once.gd new file mode 100644 index 0000000..e0625df --- /dev/null +++ b/tests/test_place_once.gd @@ -0,0 +1,478 @@ +extends SceneTree +## Editor-Test (headless): Einmal-Platzieren — Karte t_5e3e97c9 (U5). +## Ausführen: godot4 --headless --path . --script res://tests/test_place_once.gd +## Exit 0 = alles grün. +## +## Verhalten (Spec KARTEN_UI_UNITY_vc27, Karte U5): +## 1. EIN Klick setzt EIN Objekt → Platzier-Werkzeug AUS, Ghost/Panel- +## Auswahl zurück, das NEUE Objekt ist ausgewählt (Index korrekt, +## Baum + Inspektor aktualisiert), Statuszeile „Objekt gesetzt…“. +## 2. Shift beim Klick = Serienmodus: Werkzeug bleibt AN, weitere Klicks +## setzen mehrfach; die Statuszeile zeigt „Serienmodus (Shift)“. +## 3. ESC / Rechtsklick bricht ab OHNE zu setzen. +## 4. EIN Undo entfernt genau das eine gesetzte Objekt. +## 5. Auswahl bleibt stabil: kein zweites Auswahl-Ereignis für dasselbe +## Objekt (Baum-Markierung nur 1×). +## +## Instanziert die ECHTE Main-UI (Muster tests/test_rmb_vorrang.gd) und +## schickt echte Maus-/Tastatur-Events durch den SubViewport; die direkten +## interaction-Pfade laufen gegen ein Probe-Main wie tests/test_shortcuts.gd. + +var failures := 0 +var checks := 0 +var main: Control +var subvp: SubViewport +var selected_count := 0 + +# Zähler für die Auswahl-Stabilität (Baum-Markierungen). +var tree_marks := 0 + + +func _initialize() -> void: + _run_all.call_deferred() + + +func _run_all() -> void: + _clear_library_cfg_section() + var scene: PackedScene = load("res://scenes/main.tscn") + _check(scene != null, "scenes/main.tscn laedt") + if scene == null: + _finish() + return + main = scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + var container := _find_viewport_container(main) + _check(container != null, "SubViewportContainer gefunden") + if container == null: + _finish() + return + subvp = container.get_child(0) as SubViewport + _check(subvp != null, "SubViewport gefunden") + + await _test_ein_klick_setzt_einmal() + await _test_serienmodus_shift() + await _test_esc_bricht_ab() + await _test_rechtsklick_laesst_nicht_fallen() + _test_undo_genau_eins() + _test_probe_auswahl_stabil() + + _finish() + + +func _finish() -> void: + 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 _find_viewport_container(node: Node) -> SubViewportContainer: + if node is SubViewportContainer: + return node as SubViewportContainer + for child in node.get_children(): + var found := _find_viewport_container(child) + if found != null: + return found + return null + + +## Event-Position: Mitte des 3D-Viewports (SubViewport-Koordinaten) — trifft +## garantiert die Karte (K1d-Startansicht: 80 m über der Kartenmitte). +func _vp_center() -> Vector2: + return subvp.size * 0.5 + + +func _mouse(button: MouseButton, pressed: bool, pos: Vector2, shift := false) -> void: + var mb := InputEventMouseButton.new() + mb.button_index = button + mb.pressed = pressed + mb.position = pos + mb.global_position = pos + mb.shift_pressed = shift + subvp.push_input(mb) + + +func _motion(pos: Vector2, shift := false) -> void: + var mm := InputEventMouseMotion.new() + mm.position = pos + mm.global_position = pos + mm.relative = Vector2.ZERO + mm.shift_pressed = shift + subvp.push_input(mm) + + +func _press_key(keycode: Key) -> InputEventKey: + var ev := InputEventKey.new() + ev.keycode = keycode + ev.physical_keycode = keycode + ev.pressed = true + return ev + + +func _objects() -> Array: + return main.doc.get("objects", []) as Array + + +func _first_oid() -> String: + var lib: Dictionary = main.manifest.get("objects", {}) + for key in lib: + return str(key) + return "" + + +# ------------------------------------------------------------ 1 Ein Klick ---- + +func _test_ein_klick_setzt_einmal() -> void: + print("Testgruppe: Ein Klick setzt EIN Objekt und beendet das Werkzeug") + var oid := _first_oid() + _check(oid != "", "Manifest hat ein Objekt zum Platzieren") + if oid == "": + return + var interaction: Node = main.interaction + var assets: Node = main.assets_mod + selected_count = 0 + assets.object_selected.connect(func(_s: String) -> void: selected_count += 1) + main.call("_set_tool_mode", "objects") + _check(int(_objects().size()) == 0, "Startzustand: leere Map (%d Objekte)" % _objects().size()) + + # Doppelklick auf die Kachel = Werkzeug-Start (U4-Vertrag). + assets._activate_tile(oid) + _check(bool(interaction.get("placing")), "Werkzeug läuft (Objekt in der Hand)") + _check(str(interaction.get("place_oid")) == oid, "place_oid stimmt (%s)" % str(interaction.get("place_oid"))) + var placed_index := _objects().size() + _check(placed_index == 0, "nichts gesetzt vor dem Klick (Index des Neuen = %d)" % placed_index) + + # EIN echter Linksklick in die Kartenmitte (ohne Shift). + var pos := _vp_center() + _motion(pos) + _mouse(MOUSE_BUTTON_LEFT, true, pos) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos) + await process_frame + + _check(_objects().size() == 1, "EIN Klick = EIN Objekt im doc (ist %d)" % _objects().size()) + _check(not bool(interaction.get("placing")), "Werkzeug nach dem Setzen AUS") + _check(str(interaction.get("place_oid")) == "", "Ghost-Auftrag geleert (place_oid leer)") + _check(int(interaction.get("selected_index")) == placed_index, + "das neue Objekt ist ausgewählt (Index %d, ist %d)" % [placed_index, int(interaction.get("selected_index"))]) + var indices_arr: Variant = interaction.get("selected_indices") + var indices: Array = indices_arr if indices_arr is Array else [] + _check(indices == ([placed_index] as Array), "Auswahl-Liste = [neues Objekt] (ist %s)" % str(indices)) + _check(str(assets.get("selected_oid")) == "", "Panel-Auswahl zurückgesetzt (Kachel leuchtet nicht mehr)") + _check(str(main.status_label.text).contains("ausgewählt"), "Statuszeile meldet Klartext („…ausgewählt“): %s" % str(main.status_label.text)) + # Inspektor zeigt das neue Objekt. + var inspector: Node = main.get("inspector") + _check(inspector != null, "Inspektor vorhanden") + if inspector != null: + var head_txt: String = str(inspector.get("_head").text) + _check(not str(head_txt).is_empty() and head_txt != "Objekt-Inspektor", + "Inspektor zeigt das neue Objekt (%s)" % head_txt) + + # Zweiter Klick NACH dem Ende: wählt nichts / setzt nichts (Werkzeug ist aus). + var before := _objects().size() + _mouse(MOUSE_BUTTON_LEFT, true, pos) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos) + await process_frame + _check(_objects().size() == before, "Klick danach setzt NICHTS (Werkzeug beendet)") + _check(selected_count == 1, "Auswahl bleibt stabil: object_selected genau 1× (ist %d)" % selected_count) + await _cleanup_objects() + + +# ----------------------------------------------------------- 2 Serienmodus ---- + +func _test_serienmodus_shift() -> void: + print("Testgruppe: Shift = Serienmodus — Werkzeug bleibt AN, setzt mehrfach") + var oid := _first_oid() + var interaction: Node = main.interaction + var assets: Node = main.assets_mod + main.call("_set_tool_mode", "objects") + _check(int(_objects().size()) == 0, "Startzustand: leere Map (%d Objekte)" % _objects().size()) + assets._activate_tile(oid) + _check(bool(interaction.get("placing")), "Werkzeug läuft") + + var pos0 := _vp_center() + # Klickpunkte leicht streuen: der vp-Mittelpunkt liegt exakt auf der + # Symmetrie-Mitte (Standard Punktspiegelung) — dort wird absichtlich NICHT + # gedoppelt, der nächste Klick liegt daneben und zeigt den Zwilling. + # Für U5 unerheblich (beide Varianten setzen), aber die Zählung bleibt + # eindeutig, wenn jeder Klick off-center landet. + var pos := Vector2(pos0.x + 17.0, pos0.y + 11.0) + # Klick MIT Shift: Serienmodus — Werkzeug bleibt an, setzt trotzdem. + _motion(pos, true) + _mouse(MOUSE_BUTTON_LEFT, true, pos, true) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos, true) + await process_frame + _check(_objects().size() == 1, "1. Klick (Shift) setzt genau eins (ist %d)" % _objects().size()) + _check(bool(interaction.get("placing")), "SERIENMODUS: Werkzeug bleibt AN") + _check(str(main.status_label.text).contains("Serienmodus"), "Statuszeile nennt den Serienmodus: %s" % str(main.status_label.text)) + _check(int(interaction.get("selected_index")) == 0, "Serienmodus: das neue Objekt ist ausgewählt (Index 0)") + + # Weiterer Klick MIT Shift: setzt das zweite Objekt, Werkzeug bleibt an. + pos = Vector2(pos0.x - 23.0, pos0.y + 7.0) + _motion(pos, true) + _mouse(MOUSE_BUTTON_LEFT, true, pos, true) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos, true) + await process_frame + _check(_objects().size() >= 2 and _objects().size() % 2 == 0, "1./2. Klick (Shift) setzen (mit Zwillingen: gerade Zahl — ist %d)" % _objects().size()) + _check(bool(interaction.get("placing")), "Serienmodus läuft weiter (Werkzeug noch an)") + + # Klick OHNE Shift in der Serie: beendet das Werkzeug (normales Ende). + pos = Vector2(pos0.x + 9.0, pos0.y - 13.0) + _motion(pos) + _mouse(MOUSE_BUTTON_LEFT, true, pos) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos) + await process_frame + _check(_objects().size() == 3, "3. Klick (ohne Shift) setzt das dritte (ist %d)" % _objects().size()) + _check(not bool(interaction.get("placing")), "ohne Shift: Werkzeug beendet sich") + await _cleanup_objects() + + +# ------------------------------------------------------------- 3 ESC bricht ---- + +func _test_esc_bricht_ab() -> void: + print("Testgruppe: ESC bricht ab — nichts wird gesetzt") + var oid := _first_oid() + var interaction: Node = main.interaction + var assets: Node = main.assets_mod + main.call("_set_tool_mode", "objects") + _check(int(_objects().size()) == 0, "Startzustand: leere Map (%d Objekte)" % _objects().size()) + assets._activate_tile(oid) + _check(bool(interaction.get("placing")), "Werkzeug läuft") + + var pos := _vp_center() + _motion(pos) + main._unhandled_key_input(_press_key(KEY_ESCAPE)) + await process_frame + _check(not bool(interaction.get("placing")), "ESC beendet das Werkzeug") + _check(_objects().size() == 0, "ESC hat NICHTS gesetzt (ist %d)" % _objects().size()) + _check(str(interaction.get("place_oid")) == "", "Ghost-Auftrag weg") + _check(str(main.status_label.text).contains("abgebrochen") or str(main.status_label.text).contains("Bereit"), + "Statuszeile meldet den Abbruch (%s)" % str(main.status_label.text)) + await _cleanup_objects() + + +# ------------------------------------------------------- 4 Rechtsklick = Flug ---- + +func _test_rechtsklick_laesst_nicht_fallen() -> void: + print("Testgruppe: Rechtsklick beim Platzieren = Flug (Objekt bleibt, RMB setzt NICHT)") + var oid := _first_oid() + var interaction: Node = main.interaction + var assets: Node = main.assets_mod + var camera: Camera3D = main.get("camera") + main.call("_set_tool_mode", "objects") + assets._activate_tile(oid) + _check(bool(interaction.get("placing")), "Werkzeug läuft") + + var pos := _vp_center() + _motion(pos) + # Rechte Maustaste hat absoluten Vorrang (t_2470de89): Flug, kein Setzen, + # kein Abbruch. + _mouse(MOUSE_BUTTON_RIGHT, true, pos) + await process_frame + _check(bool(camera.call("fly_active")), "RMB → Flug an (Vorrang)") + _check(bool(interaction.get("placing")), "Objekt bleibt in der Hand (kein Abbruch, kein Setzen)") + _mouse(MOUSE_BUTTON_RIGHT, false, pos) + await process_frame + _check(not bool(camera.call("fly_active")), "RMB los → Flug aus") + _check(bool(interaction.get("placing")), "Werkzeug läuft nach dem Flug weiter") + + # ESC wirft ab (bekannter Vertrag). + main._unhandled_key_input(_press_key(KEY_ESCAPE)) + await process_frame + _check(not bool(interaction.get("placing")), "ESC wirft danach ab") + _check(_objects().size() == 0, "kein Objekt gesetzt (ist %d)" % _objects().size()) + await _cleanup_objects() + + +# ------------------------------------------------------------- 5 EIN Undo ---- + +func _test_undo_genau_eins() -> void: + print("Testgruppe: Ein Undo nimmt genau das eine gesetzte Objekt zurück") + var oid := _first_oid() + var interaction: Node = main.interaction + var assets: Node = main.assets_mod + main.call("_set_tool_mode", "objects") + await _cleanup_objects() + _check(int(_objects().size()) == 0, "Startzustand: leere Map (%d Objekte)" % _objects().size()) + assets._activate_tile(oid) + var pos := _vp_center() + _motion(pos) + _mouse(MOUSE_BUTTON_LEFT, true, pos) + await process_frame + _mouse(MOUSE_BUTTON_LEFT, false, pos) + await process_frame + _check(_objects().size() == 1, "Objekt gesetzt (ist %d)" % _objects().size()) + _check(main.history.can_undo(), "History hat den Platzier-Schritt") + _check(str(main.history.undo_desc()).contains("platzieren"), "Undo-Beschreibung nennt das Platzieren (%s)" % str(main.history.undo_desc())) + + main._do_undo() + await process_frame + _check(_objects().size() == 0, "EIN Undo: das eine Objekt ist weg (ist %d)" % _objects().size()) + _check(not main.history.can_undo(), "History ist leer (kein weiterer Schritt)") + _check(int(interaction.get("selected_index")) == -1, "Auswahl nach dem Undo geleert (Index -1)") + await _cleanup_objects() + + +# --------------------------------------------------- 6 Auswahl-Stabilität ---- + +## Direkter Pfad (Probe-Main wie tests/test_shortcuts.gd): nach _place_object +## wird die Baum-Markierung für das neue Objekt GENAU EINMAL gesetzt — kein +## doppeltes Auswahl-Ereignis (1× in der Platzier-Meldung, 1× im Werkzeug-Ende). +func _test_probe_auswahl_stabil() -> void: + print("Testgruppe: Auswahl stabil — Baum-Markierung genau 1× je Platzierung") + var doc := SvmapDocument.create_new("U5Test", 1024.0, "mittag") + var fake := ProbeMain.new() + fake.doc = doc + fake.manifest = {"objects": {"crate_wood": {"poly": 12, "collision": {"shape": "box", "size": [1.2, 1.2, 1.2]}}}} + fake.sym_mode = SymmetryCore.Mode.OFF # Auswahl-Stabilität, nicht M3 + fake.history.begin(doc) + var vp := SubViewport.new() + vp.size = Vector2i(1280, 720) + root.add_child(vp) + var cam := Camera3D.new() + vp.add_child(cam) + cam.current = true + cam.look_at_from_position(Vector3(-95.0, 115.0, 135.0), Vector3.ZERO, Vector3.UP) + var builder := ProbeBuilder.new() + var interaction := Node3D.new() + interaction.set_script(load("res://src/editor/interaction.gd")) + vp.add_child(interaction) + interaction.setup(fake, cam, builder) + + # Werkzeug an (wie start_placement, ohne GLB-Abhängigkeit). + interaction.set("placing", true) + interaction.set("place_oid", "crate_wood") + var h := SvmapDocument.terrain_height_at(doc, 100.0, 40.0) + interaction.call("_place_object", cam.unproject_position(Vector3(100.0, h, 40.0))) + var objs: Array = doc.get("objects", []) + _check(objs.size() == 1, "Probe: 1 Objekt gesetzt (ist %d)" % objs.size()) + _check(not bool(interaction.get("placing")), "Probe: Werkzeug nach dem Setzen AUS") + _check(int(interaction.get("selected_index")) == 0, "Probe: neues Objekt ausgewählt (Index 0)") + _check(fake.tree_marks == 1, "Probe: Baum-Markierung GENAU 1× (ist %d)" % fake.tree_marks) + _check(fake.statuses.size() > 0 and str(fake.statuses[fake.statuses.size() - 1]).contains("ausgewählt"), + "Probe: letzte Meldung sagt „ausgewählt“ (%s)" % str(fake.statuses.back() if not fake.statuses.is_empty() else "")) + # Serienmodus-Gegenprobe: mit Shift bleibt das Werkzeug an. + var inter2 := Node3D.new() + inter2.set_script(load("res://src/editor/interaction.gd")) + vp.add_child(inter2) + var doc2 := SvmapDocument.create_new("U5Test2", 1024.0, "mittag") + var fake2 := ProbeMain.new() + fake2.doc = doc2 + fake2.manifest = {"objects": {"crate_wood": {"poly": 12, "collision": {"shape": "box", "size": [1.2, 1.2, 1.2]}}}} + fake2.sym_mode = SymmetryCore.Mode.OFF # Serienmodus-Zählung ohne Zwillinge + fake2.history.begin(doc2) + inter2.setup(fake2, cam, builder) + inter2.set("placing", true) + inter2.set("place_oid", "crate_wood") + inter2.call("_place_object", cam.unproject_position(Vector3(100.0, h, 40.0)), true) + _check(bool(inter2.get("placing")), "Probe Serienmodus: Werkzeug bleibt AN (Shift)") + _check((doc2["objects"] as Array).size() == 1, "Probe Serienmodus: Objekt gesetzt") + _check(str(fake2.statuses.back() if not fake2.statuses.is_empty() else "").contains("Serienmodus"), + "Probe: Statuszeile nennt „Serienmodus (Shift)“") + # Zweiter serieller Klick: zweites Objekt, immer noch AN. + inter2.set("placing", true) + inter2.set("place_oid", "crate_wood") + inter2.call("_place_object", cam.unproject_position(Vector3(-100.0, h, -40.0)), true) + _check((doc2["objects"] as Array).size() == 2, "Probe Serienmodus: 2 Objekte gesetzt") + _check(bool(inter2.get("placing")), "Probe Serienmodus: immer noch AN") + vp.queue_free() + + +# -------------------------------------------------------------- Helfer ---- + +## Test-Map leeren (Undo-History bleibt bewusst stehen — die nächste Gruppe +## baut ihren eigenen Zustand auf). Alle Objekte hart entfernen. +func _cleanup_objects() -> void: + # Symmetrie aus (Standard ist Punkt) — die Serien-/Einmal-Zählung soll + # nicht von automatischen Zwillingen abhängen (M3, separate Karte). + main.sym_mode = SymmetryCore.Mode.OFF + var objects: Array = main.doc.get("objects", []) + objects.clear() + main.view_builder.call("rebuild", main.doc, main.manifest) + var interaction: Node = main.interaction + interaction.call("clear_selection", true) + main.history.clear() + await process_frame + + +func _clear_library_cfg_section() -> void: + # Nur UNSEREN Abschnitt leeren — [test]/[update]/[fenster] bleiben. + var cfg := ConfigFile.new() + cfg.load("user://scopedvr_editor.cfg") + if cfg.has_section("library"): + cfg.erase_section("library") + cfg.save("user://scopedvr_editor.cfg") + + +## Probe-main-Doppel (wie tests/test_shortcuts.gd ProbeMain): zählt +## Baum-Markierungen mit, damit die Auswahl-Stabilität beweisbar ist. +class ProbeMain: + var doc: Dictionary = {} + var manifest: Dictionary = {} + var grid_snap := false + var dirty := false + var sym_mode: int = SymmetryCore.Mode.OFF # U5-Probe: Symmetrie bewusst aus + var history: ActionHistory = ActionHistory.new() + var statuses: Array = [] + var tree_marks := 0 + + func object_label(_oid: String) -> String: + return "Kiste" + + func _fmt_int(v: int) -> String: + return str(v) + + func _set_status(text: String, _err := false) -> void: + statuses.append(text) + + func _highlight_library(_oid: String) -> void: + pass + + func _selection_status(_idx: int) -> void: + pass + + func _sync_selection_panel() -> void: + pass + + func _mark_tree_selection(_idx: int) -> void: + tree_marks += 1 + + func _mark_tree_selection_multi(_indices: Array) -> void: + pass + + func _after_structure_change() -> void: + pass + + func _refresh_history_buttons() -> void: + pass + + func _refresh_stats() -> void: + pass + + func _refresh_file_label() -> void: + pass + + +class ProbeBuilder extends Node3D: + var object_markers: Array = [] + var last_selection: Array = [] + + func set_selected_multi(indices: Array) -> void: + last_selection = indices.duplicate() diff --git a/tests/test_place_once.gd.uid b/tests/test_place_once.gd.uid new file mode 100644 index 0000000..74dd99f --- /dev/null +++ b/tests/test_place_once.gd.uid @@ -0,0 +1 @@ +uid://cl4x63iimyv82