"""Offscreen tests for the BOM window's drag/drop path (no display needed — QGraphicsScene is pure Qt). Guards the placement-id vs cut-item-id crash.""" import os import pytest os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6") from PySide6.QtWidgets import QApplication # noqa: E402 from woodshop.cutplan import find_placement # noqa: E402 from woodshop.gui.bom_window import BomWindow, _Piece # noqa: E402 from woodshop.gui.controller import Controller # noqa: E402 _app = QApplication.instance() or QApplication([]) def _pieces(w): return sorted((it for it in w.scene.items() if isinstance(it, _Piece)), key=lambda it: it.pos().x()) def test_drop_overlap_reverts_without_crashing(tmp_path): c = Controller(str(tmp_path / "s.json")) c.place("2x4", 30) c.place("2x4", 30) # one stick, two pieces w = BomWindow(c) first, second = _pieces(w)[:2] home = (second.sp_id, second.pos().x(), second.pos().y()) second.setPos(0, second.pos().y()) # drop on top of the first -> overlap w._drop_piece(second, home) # must not raise (was StopIteration) assert "revert" in w._status.text().lower() def test_drop_onto_incompatible_stock_reverts(tmp_path): c = Controller(str(tmp_path / "s.json")) c.place("2x4", 24) c.place("ply-3/4", 24, width_in=24) w = BomWindow(c) lumber = next(it for it in _pieces(w) if not find_placement(w._plan, it.pid)[0].is_sheet) sheet_y = next(y0 for y0, _y1, sp in w._rows if sp.is_sheet) home = (lumber.sp_id, lumber.pos().x(), lumber.pos().y()) lumber.setPos(10, sheet_y + 5) # drag the 2x4 onto the plywood sheet w._drop_piece(lumber, home) # must not raise assert "can't go" in w._status.text() def test_best_of_n_button_keeps_valid_plan(tmp_path): from woodshop.cutplan import validate_cut_plan c = Controller(str(tmp_path / "s.json")) for ln in (50, 46, 40, 30): c.place("2x4", ln) w = BomWindow(c) w._best_of_n() # no locks -> best of 100 assert validate_cut_plan(w._plan) == [] assert "best" in w._status.text().lower() def test_cost_tab_renders_estimate(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) c = Controller(str(tmp_path / "s.json")) for _ in range(3): c.place("2x4", 40) w = BomWindow(c) text = w._cost_te.toPlainText() assert "PROJECT ESTIMATE" in text and "TOTAL COST" in text and "SUGGESTED PRICE" in text def test_margin_spin_updates_price_and_persists(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) from woodshop import estimate as E c = Controller(str(tmp_path / "s.json")) c.place("2x4", 40) w = BomWindow(c) w._margin_spin.setValue(50.0) # fires _on_margin_changed assert "50%" in w._cost_te.toPlainText() assert E.load_rates().margin_pct == 50.0 def test_target_overrides_margin(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) c = Controller(str(tmp_path / "s.json")) c.place("2x4", 40) w = BomWindow(c) w._target_spin.setValue(500.0) assert "Target price" in w._cost_te.toPlainText() assert "$500.00" in w._cost_te.toPlainText() def test_edit_prices_persists_and_updates(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) from woodshop import prices as P c = Controller(str(tmp_path / "s.json")) c.place("2x4", 40) w = BomWindow(c) w._prices["2x4"] = 10.00 # simulate an edit accepted from the dialog P.save_prices(w._prices) w._cost_te.setPlainText(w._cost_text()) assert "$11.50" in w._cost_te.toPlainText() # 1 stick × $10 + 15% HST assert P.load_prices()["2x4"] == 10.00 def test_best_of_n_with_lock_runs_and_validates(tmp_path): from woodshop.cutplan import validate_cut_plan c = Controller(str(tmp_path / "s.json")) for ln in (50, 46, 40, 30): c.place("2x4", ln) w = BomWindow(c) p = next(p for sp in w._plan.stock_pieces for p in sp.placements) p.locked = True w._best_of_n() # locked path: strategies + shuffles assert validate_cut_plan(w._plan) == [] assert "locked" in w._status.text() def test_valid_move_commits(tmp_path): c = Controller(str(tmp_path / "s.json")) c.place("2x4", 20) c.place("2x4", 20) w = BomWindow(c) second = _pieces(w)[1] home = (second.sp_id, second.pos().x(), second.pos().y()) second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear w._drop_piece(second, home) assert "placed" in w._status.text().lower() def test_offcut_toggle_uses_inventory(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) from woodshop import inventory as I led = I.Ledger() led.record_build("seed", 1, consumed={}, offcuts=[ {"stock": "2x4", "length_in": 96, "width_in": 3.5, "is_sheet": False}]) led.save() c = Controller(str(tmp_path / "s.json")) c.place("2x4", 30) w = BomWindow(c) assert w._plan.score["stock_count"] == 1 # buys 1 stick by default w._offcut_chk.setChecked(True) # use the 96" offcut instead assert w._plan.score["stock_count"] == 0 assert w._plan.score["owned_count"] == 1 def test_record_build_writes_ledger(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) from woodshop import inventory as I c = Controller(str(tmp_path / "s.json")) c.place("2x4", 60) w = BomWindow(c) consumed, offcuts = I.plan_consumption(w._plan) w._ledger.record_build("t", 1, consumed, offcuts, dispositions=["keep"] * len(offcuts)) w._ledger.save() again = I.Ledger.load() assert again.builds() and again.stats()["units_built"] == 1 def test_optimize_preserves_offcut_toggle(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) from woodshop import inventory as I led = I.Ledger() led.record_build("seed", 1, consumed={}, offcuts=[ {"stock": "2x4", "length_in": 96, "width_in": 3.5, "is_sheet": False}]) led.save() c = Controller(str(tmp_path / "s.json")) c.place("2x4", 30) w = BomWindow(c) w._offcut_chk.setChecked(True) assert w._plan.score["stock_count"] == 0 w._optimize() # Codex #1: must NOT lose the offcut assert w._plan.score["stock_count"] == 0 w._best_of_n() assert w._plan.score["stock_count"] == 0