196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
"""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_paths=None, reference_text=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_paths(tmp_path, monkeypatch):
|
|
c = _controller(tmp_path)
|
|
seen = {}
|
|
|
|
def fake_interpret(text, schemas, scene_text=None, history=None, image_paths=None, reference_text=None):
|
|
seen["image_paths"] = image_paths
|
|
return [{"tool": "say", "args": {"text": "ok"}}]
|
|
|
|
monkeypatch.setattr(driver, "interpret", fake_interpret)
|
|
c.run_command("build like these", image_paths=["/tmp/a.jpg", "/tmp/b.jpg"])
|
|
assert seen["image_paths"] == ["/tmp/a.jpg", "/tmp/b.jpg"]
|
|
|
|
|
|
def test_refine_to_match_critiques_and_applies(tmp_path, monkeypatch):
|
|
c = _controller(tmp_path)
|
|
c.place("2x4", 24)
|
|
monkeypatch.setattr(c, "render_views", lambda views=("front", "side", "iso"):
|
|
["/r/front.png", "/r/iso.png"])
|
|
seen = {}
|
|
|
|
def fake_critique(refs, renders, schemas, scene_text=None, history=None):
|
|
seen["refs"], seen["renders"] = refs, renders
|
|
return [{"tool": "say", "args": {"text": "LGTM looks right"}}]
|
|
|
|
monkeypatch.setattr(driver, "critique", fake_critique)
|
|
out = c.refine_to_match(["/ref/a.png"], None, rounds=3)
|
|
assert seen["renders"] == ["/r/front.png", "/r/iso.png"]
|
|
assert "LGTM" in out # stopped after first round
|
|
|
|
|
|
def test_refine_to_match_handles_no_render(tmp_path, monkeypatch):
|
|
c = _controller(tmp_path)
|
|
monkeypatch.setattr(c, "render_views", lambda views=("front", "side", "iso"): [])
|
|
out = c.refine_to_match(["/ref/a.png"], None)
|
|
assert "couldn't render" in out.lower()
|