woodshop/tests/test_bom_window.py

199 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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