199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
"""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
|
||
|
||
|
||
def test_purchase_dialog_receipt_fills_prices(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
||
from collections import Counter
|
||
from woodshop.gui.bom_window import PurchaseDialog
|
||
from PySide6.QtCore import QThreadPool
|
||
bought = Counter({("2x4", "spruce"): 3, ("ply-3/4", "spruce-ply"): 1})
|
||
dlg = PurchaseDialog(bought, {"2x4": 4.0, "ply-3/4": 60.0}, pool=QThreadPool.globalInstance())
|
||
# simulate the worker's done() applying receipt-read prices
|
||
dlg._by_label["2x4"].setValue(0.0)
|
||
prices_map = {"2x4": 3.49, "ply-3/4": 58.0, "unknown": 9.0}
|
||
n = 0
|
||
for label, price in prices_map.items():
|
||
spin = dlg._by_label.get(label)
|
||
if spin is not None and price > 0:
|
||
spin.setValue(price); n += 1
|
||
assert n == 2
|
||
rows, _ = dlg.result()
|
||
by_stock = {s: pr for s, _m, _q, pr in rows}
|
||
assert by_stock["2x4"] == 3.49 and by_stock["ply-3/4"] == 58.0
|