"""Tests for the GUI controller's in-process command execution (no display).""" import pytest pytest.importorskip("PySide6") from PySide6.QtCore import QCoreApplication # noqa: E402 from woodshop import driver # noqa: E402 from woodshop.gui.controller import Controller # noqa: E402 _app = QCoreApplication.instance() or QCoreApplication([]) def _controller(tmp_path): return Controller(str(tmp_path / "scene.json")) def test_execute_calls_with_symbols(tmp_path): """The controller's executor applies wood-* calls in-process, with $N.""" c = _controller(tmp_path) calls = [ {"tool": "wood-place", "args": {"stock": "2x4", "length": "4 ft"}}, {"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}}, {"tool": "wood-stand", "args": {"part": "$2"}}, {"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "0"}}, ] driver.dispatch(calls, verbose=False, executor=c.execute_call) assert [p.id for p in c.scene.parts] == ["p1", "p2"] assert c.scene.get_part("p2").is_vertical assert len(c.scene.joints) == 1 def test_button_ops_and_persistence(tmp_path): c = _controller(tmp_path) c.place("2x4", 48) c.stand() # acts on selection (p1) assert c.scene.get_part("p1").is_vertical c.duplicate() # p2 assert len(c.scene.parts) == 2 c.delete() # deletes selection p2 assert [p.id for p in c.scene.parts] == ["p1"] # changes are persisted to disk from woodshop.scene import Scene assert len(Scene.load(c.scene_path).parts) == 1 def test_select_and_undo_redo(tmp_path): c = _controller(tmp_path) c.place("2x4", 24) c.place("2x4", 36) c.select("p1") assert c.selected_id == "p1" c.undo() # removes p2 assert len(c.scene.parts) == 1 c.redo() assert len(c.scene.parts) == 2 def test_toggle_multiselect(tmp_path): c = _controller(tmp_path) c.place("2x4", 24) c.place("2x4", 24) c.select("p1") c.toggle("p2") assert set(c.selected) == {"p1", "p2"} c.toggle("p1") # ctrl-click again removes it assert c.selected == ["p2"] def test_group_move_is_single_undo(tmp_path): c = _controller(tmp_path) for _ in range(3): c.place("2x4", 24) c.set_selected(["p1", "p2", "p3"]) c.move_selected(dy=4) # "move these 4 inches in +y" assert all(p.position_in[1] == 4 for p in c.scene.parts) c.undo() # one undo reverts the whole group assert all(p.position_in[1] == 0 for p in c.scene.parts) def test_feature_preview_then_apply(tmp_path): c = _controller(tmp_path) c.place("2x4", 12) c.add_feature("mortise") # active feature with defaults orig = c.active_feature_obj().depth_in c.set_preview(depth_in=orig + 0.5) # preview only — model unchanged assert c.preview is not None assert c.active_feature_obj().depth_in == orig c.apply_preview() # commit assert c.preview is None assert c.active_feature_obj().depth_in == orig + 0.5 def test_feature_preview_mesh_builds(): pytest.importorskip("pyvista") from woodshop.scene import Scene from woodshop.viewer import feature_preview_mesh s = Scene(); s.place("2x4", 12) feat = s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5) assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0 def test_fit_mortise_to_tenon(tmp_path): c = _controller(tmp_path) c.place("2x4", 24) c.add_feature("tenon") # f1 on p1, active c.scene.edit_feature("f1", width_in=1.0, height_in=0.75, depth_in=1.5) c.place("2x4", 24) c.add_feature("mortise") # f2 on p2, now active c.fit_feature("f1") # fit the mortise to the tenon _, m = c.scene.find_feature("f2") assert m.width_in == 1.0 + 1 / 32 # pocket = tongue + clearance assert m.height_in == 0.75 + 1 / 32 assert m.depth_in == 1.5 + 1 / 32 def test_highlight_feature(tmp_path): c = _controller(tmp_path) c.place("2x4", 12) c.add_feature("mortise") # f1 c.highlight_feature("f1") assert c.preview is not None and c.preview_kind == "highlight" assert c.preview[1].id == "f1" c.highlight_feature(None) assert c.preview is None def test_break_feature_connection(tmp_path): c = _controller(tmp_path) c.place("2x4", 24); c.add_feature("mortise") # f1 on p1 c.place("2x4", 12); c.add_feature("tenon") # f2 on p2 c.scene.connect("f1", "f2") assert c.feature_connection_ids("f1") == ["c1"] c.break_feature_connection("f1") assert c.scene.connections == [] assert c.feature_connection_ids("f1") == [] def test_unknown_tool_is_safe(tmp_path): c = _controller(tmp_path) assert "unknown" in c.execute_call("wood-bogus", {}).lower() def test_run_command_threads_history(tmp_path, monkeypatch): """run_command feeds prior turns to interpret and records the new turn.""" c = _controller(tmp_path) seen = {} def fake_interpret(text, schemas, scene_text=None, history=None, image_path=None): seen["history"] = list(history or []) return [{"tool": "say", "args": {"text": "want me to add tenons?"}}] monkeypatch.setattr(driver, "interpret", fake_interpret) c.run_command("build a table") assert seen["history"] == [] # first turn: nothing prior assert c._history == [("build a table", "want me to add tenons?")] c.run_command("yes") assert seen["history"] == [("build a table", "want me to add tenons?")] def test_run_command_forwards_image_path(tmp_path, monkeypatch): c = _controller(tmp_path) seen = {} def fake_interpret(text, schemas, scene_text=None, history=None, image_path=None): seen["image_path"] = image_path return [{"tool": "say", "args": {"text": "ok"}}] monkeypatch.setattr(driver, "interpret", fake_interpret) c.run_command("build like this", image_path="/tmp/ref.jpg") assert seen["image_path"] == "/tmp/ref.jpg"