"""Tests for the scene model and operations (no heavy 3D deps required).""" import math import pytest from woodshop.lumber import actual_section, normalize_stock from woodshop.scene import Scene, SceneError from woodshop.units import to_inches # ----- lumber ---------------------------------------------------------- def test_nominal_to_actual(): assert actual_section("2x4") == (1.5, 3.5) assert actual_section("4x4") == (3.5, 3.5) @pytest.mark.parametrize("raw,expected", [("2 x 4", "2x4"), ("2X4", "2x4"), ("2by4", "2x4")]) def test_normalize_stock(raw, expected): assert normalize_stock(raw) == expected def test_unknown_stock_lists_options(): with pytest.raises(KeyError, match="Known stock"): actual_section("9x9") # ----- units ----------------------------------------------------------- @pytest.mark.parametrize("value,unit,inches", [ ("6 ft", "inch", 72), ("6 foot", "inch", 72), ("10 inches", "inch", 10), ("3 ft 6 in", "inch", 42), ("2'", "inch", 24), ("72", "inch", 72), ("6", "foot", 72), (6, "foot", 72), ]) def test_to_inches(value, unit, inches): assert to_inches(value, default_unit=unit) == inches def test_to_inches_bad(): with pytest.raises(ValueError): to_inches("a bunch") # ----- operations ------------------------------------------------------ def test_place_sets_section_and_selection(): s = Scene() p = s.place("2x4", 72) assert p.id == "p1" assert p.section_in == (1.5, 3.5) assert s.selection == "p1" def test_the_example_sentence(): """'place a 6 foot 2x4, sand it, attach a 2 foot 2x4 at 90 deg, 10 in from end.'""" s = Scene() s.place("2x4", to_inches("6 ft")) # p1 s.finish("it") # sand the selection s.place("2x4", to_inches("2 ft")) # p2 (now selected) s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b") p1, p2 = s.get_part("p1"), s.get_part("p2") assert "sanded" in p1.finishes # attach point is 10in back from p1's far end (72 - 10 = 62 along +X) assert p2.position_in[0] == pytest.approx(62.0) # p2 rests on p1's top face: z = t_a/2 + t_b/2 = 0.75 + 0.75 assert p2.position_in[2] == pytest.approx(1.5) assert p2.yaw_deg == pytest.approx(90.0) # p2 now runs along +Y ux, uy, uz = p2.axis_unit() assert ux == pytest.approx(0.0, abs=1e-9) assert uy == pytest.approx(1.0) assert uz == pytest.approx(0.0, abs=1e-9) assert len(s.joints) == 1 def test_stand_makes_board_vertical(): s = Scene() s.place("2x4", 30) s.stand("it") p = s.get_part("p1") assert p.is_vertical ux, uy, uz = p.axis_unit() assert uz == pytest.approx(1.0) # length axis points straight up assert (ux, uy) == pytest.approx((0.0, 0.0), abs=1e-9) assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up def test_join_preserves_vertical_tilt(): """A stood-up leg stays vertical when attached to a horizontal apron.""" s = Scene() s.place("2x4", 48) # p1 apron s.place("2x4", 29) # p2 leg s.stand("p2") s.join("p1", "p2", angle_deg=0, offset_in=0, anchor="end_a") leg = s.get_part("p2") assert leg.is_vertical # base sits on the apron's top face (z = t_a/2 = 0.75) since the leg is vertical assert leg.position_in[2] == pytest.approx(0.75) def test_move_relative_and_absolute(): s = Scene() s.place("2x4", 24) s.move("it", dx=5, dy=2, dz=1) assert s.get_part("p1").position_in == [5.0, 2.0, 1.0] s.move("it", dx=10, dy=0, dz=0, absolute=True) assert s.get_part("p1").position_in == [10.0, 0.0, 0.0] def test_copy_and_set_length_and_rename(): s = Scene() s.place("2x4", 24) s.rename("p1", "front rail") assert s.get_part("front rail").id == "p1" # resolvable by alias clone = s.copy("p1", dy=10) assert clone.id == "p2" assert clone.position_in[1] == 10.0 s.set_length("p2", 36) assert s.get_part("p2").length_in == 36.0 def test_clear(): s = Scene() s.place("2x4", 24) s.place("2x4", 24) s.clear() assert s.parts == [] and s.selection is None def test_migrate_old_rotation_field(tmp_path): """Scenes saved with the old rotation_deg field still load.""" import json old = {"version": 1, "parts": [{"id": "p1", "stock": "2x4", "length_in": 24, "section_in": [1.5, 3.5], "position_in": [0, 0, 0], "rotation_deg": 45}]} path = tmp_path / "old.json" path.write_text(json.dumps(old)) s = Scene.load(path) assert s.get_part("p1").yaw_deg == 45 def test_slugify(): from woodshop.scene import slugify assert slugify("Coffee Table!") == "coffee-table" assert slugify(" My Bench ") == "my-bench" def test_project_save_open_list(tmp_path, monkeypatch): import woodshop.scene as scene_mod monkeypatch.setattr(scene_mod, "_data_dir", lambda: tmp_path) s = Scene() s.place("2x4", 48) s.place("2x4", 24) s.save(scene_mod.project_path("coffee table")) assert scene_mod.list_projects() == ["coffee-table"] reopened = Scene.load(scene_mod.project_path("Coffee Table")) # name normalizes assert len(reopened.parts) == 2 def test_resolve_it_without_selection_errors(): s = Scene() with pytest.raises(SceneError, match="selected"): s.finish("it") def test_undo_restores_previous_state(): s = Scene() s.place("2x4", 72) s.place("2x4", 24) assert len(s.parts) == 2 s.undo() assert len(s.parts) == 1 assert s.selection == "p1" def test_delete_reassigns_selection_and_drops_joints(): s = Scene() s.place("2x4", 72) s.place("2x4", 24) s.join("p1", "p2") s.delete("p2") assert [p.id for p in s.parts] == ["p1"] assert s.joints == [] assert s.selection == "p1" def test_roundtrip_serialization(tmp_path): s = Scene() s.place("2x4", 72) s.place("2x4", 24) s.join("p1", "p2", angle_deg=90, offset_in=10) path = s.save(tmp_path / "scene.json") loaded = Scene.load(path) assert [p.id for p in loaded.parts] == ["p1", "p2"] assert loaded.parts[0].section_in == (1.5, 3.5) assert loaded.joints[0].angle_deg == 90 assert loaded.selection == "p2"