Fix material/inventory boundary + offcut-preservation (Codex review)
1. Offcut reuse was lost on optimize: Find better layout / Best of 100 / Try alternative now pass available=self._available(); reoptimize seeds preserve owned/source so a locked offcut stays owned (not silently bought). 2. Inventory is now species-aware end to end: purchase/consume/adjust/on_hand/ available_stock and record_build key by (stock, material); plan_consumption and Mark-purchased group by species; PurchaseDialog shows species and prices at the species rate; price-book save backs out the multiplier to the SPF base. A spruce on-hand no longer satisfies an oak cut. 3. Cross-species placement is now invalid: validate_cut_plan and the GUI drop path reject an oak cut on a spruce piece. 4. Yield is bought-only and consistent: _score divides bought-used by bought-area (owned offcuts excluded); the Shopping tab's yield matches. tests: locked-reopt keeps owned offcut, species-aware on-hand, cross-species validate, yield excludes owned, optimize preserves the offcut toggle. 203 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36d02fcb73
commit
01c4dee0bc
|
|
@ -511,12 +511,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
|||
|
||||
|
||||
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||
waste_area = used_area = bought_area = 0.0
|
||||
waste_area = bought_used = bought_area = 0.0
|
||||
reusable = 0
|
||||
bought = [sp for sp in stock_pieces if not sp.owned]
|
||||
for sp in stock_pieces:
|
||||
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
||||
used_area += used
|
||||
if not sp.owned:
|
||||
bought_used += used # yield is about BOUGHT stock only
|
||||
if sp.is_sheet:
|
||||
if not sp.owned:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
|
|
@ -537,7 +538,7 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
|
|||
"waste_area": round(waste_area, 1),
|
||||
"reusable_offcuts": reusable,
|
||||
"reusable_in": round(reusable_in, 1),
|
||||
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
||||
"yield_pct": round(bought_used / bought_area * 100, 1) if bought_area else 0.0,
|
||||
"warnings": list(warnings),
|
||||
}
|
||||
|
||||
|
|
@ -587,6 +588,7 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
|
|||
seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece(
|
||||
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
|
||||
length_in=sp.length_in, width_in=sp.width_in, material=sp.material,
|
||||
owned=sp.owned, source=sp.source,
|
||||
placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in,
|
||||
len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
|
||||
for p in kept])
|
||||
|
|
@ -773,6 +775,8 @@ def validate_cut_plan(plan: CutPlan) -> list:
|
|||
it = items.get(p.item_id)
|
||||
if it and it.stock != sp.stock:
|
||||
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
|
||||
elif it and it.material and sp.material and it.material != sp.material:
|
||||
problems.append(f"{p.id} ({it.material}) is on a {sp.material} stock piece")
|
||||
if p.rotated and not rot_ok:
|
||||
problems.append(f"{p.id} is rotated but rotation isn't allowed")
|
||||
ps = sp.placements
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class BomWindow(QDialog):
|
|||
self._rebuild_base()
|
||||
|
||||
def _mark_purchased(self) -> None:
|
||||
bought = Counter(sp.stock for sp in self._plan.stock_pieces
|
||||
bought = Counter((sp.stock, sp.material) for sp in self._plan.stock_pieces
|
||||
if not getattr(sp, "owned", False))
|
||||
if not bought:
|
||||
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
|
||||
|
|
@ -153,16 +153,19 @@ class BomWindow(QDialog):
|
|||
if not dlg.exec():
|
||||
return
|
||||
rows, save = dlg.result()
|
||||
for stock, qty, price in rows:
|
||||
self._ledger.purchase(stock, qty, price=price)
|
||||
mults = prices_mod.load_material_multipliers()
|
||||
for stock, material, qty, price in rows:
|
||||
self._ledger.purchase(stock, qty, material=material, price=price)
|
||||
if save:
|
||||
self._prices[stock] = price
|
||||
# price book stores the SPF base; back out the species multiplier
|
||||
mult = mults.get(material, 1.0) or 1.0
|
||||
self._prices[stock] = round(price / mult, 2)
|
||||
self._ledger.save()
|
||||
if save:
|
||||
prices_mod.save_prices(self._prices)
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
QMessageBox.information(self, "Added to inventory",
|
||||
f"Added {sum(q for _, q, _ in rows)} item(s) to shop inventory.")
|
||||
f"Added {sum(q for *_, q, _ in rows)} item(s) to shop inventory.")
|
||||
|
||||
def _record_build(self) -> None:
|
||||
consumed, offcuts = plan_consumption(self._plan)
|
||||
|
|
@ -261,8 +264,8 @@ class BomWindow(QDialog):
|
|||
it = plan.item(iid)
|
||||
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
|
||||
lines += ["", "Yield (used / bought):"]
|
||||
for stock in sorted({sp.stock for sp in plan.stock_pieces}):
|
||||
sps = [sp for sp in plan.stock_pieces if sp.stock == stock]
|
||||
for stock in sorted({sp.stock for sp in bought}): # bought only — matches score
|
||||
sps = [sp for sp in bought if sp.stock == stock]
|
||||
sheet = sps[0].is_sheet
|
||||
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
|
||||
cap = sum(sp.length_in * sp.width_in for sp in sps)
|
||||
|
|
@ -500,7 +503,8 @@ class BomWindow(QDialog):
|
|||
self._set_plan(best)
|
||||
self._status.setText("✓ optimized around locked pieces")
|
||||
else:
|
||||
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity))
|
||||
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity,
|
||||
available=self._available()))
|
||||
self._status.setText("✓ optimized")
|
||||
|
||||
def _best_of_n(self) -> None:
|
||||
|
|
@ -513,7 +517,8 @@ class BomWindow(QDialog):
|
|||
self._set_plan(best)
|
||||
self._status.setText("✓ best of 100 around locked pieces")
|
||||
else:
|
||||
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity))
|
||||
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity,
|
||||
available=self._available()))
|
||||
self._status.setText("✓ best of 100 attempts")
|
||||
|
||||
def _next_arrangement(self) -> None:
|
||||
|
|
@ -521,7 +526,8 @@ class BomWindow(QDialog):
|
|||
self._order = (self._order + 1) % len(STRATEGIES)
|
||||
st = STRATEGIES[self._order]
|
||||
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
|
||||
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity))
|
||||
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity,
|
||||
available=self._available()))
|
||||
self._set_plan(plan)
|
||||
|
||||
def _draw_layout(self) -> None:
|
||||
|
|
@ -600,12 +606,20 @@ class BomWindow(QDialog):
|
|||
if target is None:
|
||||
target = sp_cur
|
||||
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
|
||||
item_stock = plan.item(p.item_id).stock
|
||||
cut_item = plan.item(p.item_id)
|
||||
item_stock = cut_item.stock
|
||||
if item_stock != target.stock:
|
||||
self._revert(plan, item.pid, home)
|
||||
self._status.setText(f"✗ {item_stock} can't go on {target.stock} — reverted")
|
||||
recompute(plan); self._refresh_all()
|
||||
return
|
||||
# Species compatibility: an oak cut can't go on a spruce stick.
|
||||
if (cut_item.material and target.material and cut_item.material != target.material):
|
||||
self._revert(plan, item.pid, home)
|
||||
self._status.setText(f"✗ {cut_item.material} can't go on "
|
||||
f"{target.material} stock — reverted")
|
||||
recompute(plan); self._refresh_all()
|
||||
return
|
||||
row_y0 = self._row_of(target.id)
|
||||
x_in = max(item.pos().x() / px, 0.0)
|
||||
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
|
||||
|
|
@ -803,8 +817,10 @@ class PurchaseDialog(QDialog):
|
|||
|
||||
def __init__(self, bought, prices, parent=None):
|
||||
super().__init__(parent)
|
||||
from ..lumber import default_material
|
||||
self.setWindowTitle("Mark purchased")
|
||||
self.resize(360, 320)
|
||||
self.resize(380, 320)
|
||||
mults = prices_mod.load_material_multipliers()
|
||||
v = QVBoxLayout(self)
|
||||
v.addWidget(QLabel("Add these to your shop inventory?"))
|
||||
rows = sorted(bought.items())
|
||||
|
|
@ -812,16 +828,18 @@ class PurchaseDialog(QDialog):
|
|||
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
|
||||
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self._spins = []
|
||||
for r, (stock, qty) in enumerate(rows):
|
||||
name = QTableWidgetItem(stock)
|
||||
for r, ((stock, material), qty) in enumerate(rows):
|
||||
label = (f"{material} {stock}" if material and material != default_material(stock)
|
||||
else stock)
|
||||
name = QTableWidgetItem(label)
|
||||
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
|
||||
self._table.setItem(r, 0, name)
|
||||
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
|
||||
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2)
|
||||
p.setValue(float(prices.get(stock, 0.0)))
|
||||
p.setValue(round(float(prices.get(stock, 0.0)) * (mults.get(material, 1.0) or 1.0), 2))
|
||||
self._table.setCellWidget(r, 1, q)
|
||||
self._table.setCellWidget(r, 2, p)
|
||||
self._spins.append((stock, q, p))
|
||||
self._spins.append((stock, material, q, p))
|
||||
v.addWidget(self._table)
|
||||
self._save = QCheckBox("Save these prices to my price book")
|
||||
v.addWidget(self._save)
|
||||
|
|
@ -830,8 +848,8 @@ class PurchaseDialog(QDialog):
|
|||
v.addWidget(bb)
|
||||
|
||||
def result(self):
|
||||
rows = [(stock, q.value(), round(p.value(), 2))
|
||||
for stock, q, p in self._spins if q.value() > 0]
|
||||
rows = [(stock, material, q.value(), round(p.value(), 2))
|
||||
for stock, material, q, p in self._spins if q.value() > 0]
|
||||
return rows, self._save.isChecked()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -46,10 +46,12 @@ class InventoryWindow(QDialog):
|
|||
oh = self._ledger.on_hand()
|
||||
if not oh:
|
||||
return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)"
|
||||
from ..lumber import default_material
|
||||
lines = ["ON-HAND STOCK", ""]
|
||||
for stock, qty in oh.items():
|
||||
for (stock, material), qty in oh.items():
|
||||
unit = "sheet" if stock.startswith("ply-") else "stick"
|
||||
lines.append(f" {qty:>3} × {stock:<10} ({unit}s)")
|
||||
name = f"{material} {stock}" if material and material != default_material(stock) else stock
|
||||
lines.append(f" {qty:>3} × {name:<14} ({unit}s)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _offcuts_text(self) -> str:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ from collections import Counter
|
|||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, is_plywood, normalize_stock
|
||||
from .lumber import (SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, default_material,
|
||||
is_plywood, normalize_stock)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -51,7 +52,8 @@ def _data_path() -> Path:
|
|||
def plan_consumption(plan) -> tuple[dict, list[dict]]:
|
||||
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
|
||||
Offcuts are the reusable waste regions; ids are assigned later by the ledger."""
|
||||
consumed = Counter(sp.stock for sp in plan.stock_pieces if not getattr(sp, "owned", False))
|
||||
consumed = Counter((sp.stock, getattr(sp, "material", "") or default_material(sp.stock))
|
||||
for sp in plan.stock_pieces if not getattr(sp, "owned", False))
|
||||
offcuts = []
|
||||
for sp in plan.stock_pieces:
|
||||
for w in sp.waste:
|
||||
|
|
@ -97,16 +99,18 @@ class Ledger:
|
|||
return f"b{n}"
|
||||
|
||||
# ----- primitives (each appends events) -----------------------------
|
||||
def purchase(self, stock: str, qty: int, price: float | None = None,
|
||||
def purchase(self, stock: str, qty: int, material: str = "", price: float | None = None,
|
||||
date: str = "", is_sheet: bool | None = None) -> None:
|
||||
stock = normalize_stock(stock)
|
||||
sheet = is_plywood(stock) if is_sheet is None else is_sheet
|
||||
self._emit("purchase", stock=stock, qty=int(qty), is_sheet=sheet,
|
||||
price=price, date=date)
|
||||
self._emit("purchase", stock=stock, material=material or default_material(stock),
|
||||
qty=int(qty), is_sheet=sheet, price=price, date=date)
|
||||
|
||||
def adjust(self, stock: str, delta: int, reason: str = "", date: str = "") -> None:
|
||||
self._emit("adjustment", stock=normalize_stock(stock), delta=int(delta),
|
||||
reason=reason, date=date)
|
||||
def adjust(self, stock: str, delta: int, material: str = "", reason: str = "",
|
||||
date: str = "") -> None:
|
||||
stock = normalize_stock(stock)
|
||||
self._emit("adjustment", stock=stock, material=material or default_material(stock),
|
||||
delta=int(delta), reason=reason, date=date)
|
||||
|
||||
def discard_offcut(self, offcut_id: str, fate: str, date: str = "") -> None:
|
||||
self._emit("discard", offcut_id=offcut_id, fate=fate, date=date)
|
||||
|
|
@ -122,9 +126,12 @@ class Ledger:
|
|||
bin), "burned"/"trashed" (→ discard), or "ignore" (don't track). Defaults
|
||||
to keeping every offcut. Returns the build id."""
|
||||
build_id = self._next_build_id()
|
||||
for stock, qty in consumed.items():
|
||||
if qty:
|
||||
self._emit("consume", stock=normalize_stock(stock), qty=int(qty),
|
||||
for key, qty in consumed.items():
|
||||
if not qty:
|
||||
continue
|
||||
stock, material = key if isinstance(key, tuple) else (key, default_material(key))
|
||||
self._emit("consume", stock=normalize_stock(stock),
|
||||
material=material or default_material(stock), qty=int(qty),
|
||||
build_id=build_id)
|
||||
disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
|
||||
for i, oc in enumerate(offcuts):
|
||||
|
|
@ -142,14 +149,19 @@ class Ledger:
|
|||
|
||||
# ----- derived state (fold the events) ------------------------------
|
||||
def on_hand(self) -> dict:
|
||||
"""Full units on hand, keyed by (stock, material)."""
|
||||
c = Counter()
|
||||
|
||||
def key(e):
|
||||
return (e["stock"], e.get("material") or default_material(e["stock"]))
|
||||
|
||||
for e in self.events:
|
||||
if e["type"] == "purchase":
|
||||
c[e["stock"]] += e["qty"]
|
||||
c[key(e)] += e["qty"]
|
||||
elif e["type"] == "consume" and "stock" in e:
|
||||
c[e["stock"]] -= e["qty"]
|
||||
c[key(e)] -= e["qty"]
|
||||
elif e["type"] == "adjustment":
|
||||
c[e["stock"]] += e["delta"]
|
||||
c[key(e)] += e["delta"]
|
||||
return {k: v for k, v in sorted(c.items()) if v}
|
||||
|
||||
def offcut_bin(self) -> list[Piece]:
|
||||
|
|
@ -172,15 +184,15 @@ class Ledger:
|
|||
"""Everything the planner could consume: full on-hand units (given real
|
||||
dimensions so they pack) + offcuts."""
|
||||
pieces = list(self.offcut_bin())
|
||||
for stock, qty in self.on_hand().items():
|
||||
for (stock, material), qty in self.on_hand().items():
|
||||
sheet = is_plywood(stock)
|
||||
if sheet:
|
||||
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
|
||||
else:
|
||||
L, W = stick_len_in, actual_section(stock)[1]
|
||||
for i in range(qty):
|
||||
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock,
|
||||
length_in=L, width_in=W, is_sheet=sheet))
|
||||
pieces.append(Piece(id=f"{stock}:{material}#{i + 1}", stock=stock,
|
||||
length_in=L, width_in=W, is_sheet=sheet, material=material))
|
||||
return pieces
|
||||
|
||||
def builds(self) -> list[dict]:
|
||||
|
|
|
|||
|
|
@ -156,3 +156,22 @@ def test_record_build_writes_ledger(tmp_path, monkeypatch):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -357,3 +357,46 @@ def test_batch_estimate_per_unit():
|
|||
assert est.quantity == 4
|
||||
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
|
||||
assert abs(est.per_unit_price - est.price / 4) < 0.01
|
||||
|
||||
|
||||
def test_locked_reoptimize_preserves_owned_offcut():
|
||||
"""Codex #1: a locked offcut must stay owned through reoptimize, not become bought."""
|
||||
from woodshop.cutplan import reoptimize
|
||||
from woodshop.inventory import Piece
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
|
||||
is_sheet=False, is_offcut=True)
|
||||
plan = build_cut_plan(s, available=[offcut])
|
||||
assert plan.score["stock_count"] == 0
|
||||
owned_piece = next(sp for sp in plan.stock_pieces if sp.owned)
|
||||
owned_piece.placements[0].locked = True
|
||||
re = reoptimize(s, plan, "decreasing")
|
||||
assert re.score["stock_count"] == 0 # still bought nothing
|
||||
assert re.score["owned_count"] == 1
|
||||
|
||||
|
||||
def test_validate_flags_cross_species():
|
||||
"""Codex #3: an oak cut on a spruce stick is invalid."""
|
||||
from woodshop.cutplan import relocate
|
||||
s = Scene()
|
||||
s.place("1x4", 24) # spruce
|
||||
s.place("1x4", 24)
|
||||
s.set_material("p2", "oak")
|
||||
plan = build_cut_plan(s) # two species → two pieces
|
||||
spruce_sp = next(sp for sp in plan.stock_pieces if sp.material == "spruce")
|
||||
oak_sp = next(sp for sp in plan.stock_pieces if sp.material == "oak")
|
||||
relocate(plan, oak_sp.placements[0].id, spruce_sp.id, 40.0)
|
||||
assert any("spruce stock piece" in p for p in validate_cut_plan(plan))
|
||||
|
||||
|
||||
def test_yield_excludes_owned_offcuts():
|
||||
"""Codex #4: an owned-only plan shouldn't report 0% (div by bought=0) misleadingly."""
|
||||
from woodshop.inventory import Piece
|
||||
s = Scene()
|
||||
s.place("2x4", 30)
|
||||
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
|
||||
is_sheet=False, is_offcut=True)
|
||||
plan = build_cut_plan(s, available=[offcut])
|
||||
assert plan.score["yield_pct"] == 0.0 # nothing bought → no bought-yield
|
||||
assert plan.score["stock_count"] == 0
|
||||
|
|
|
|||
|
|
@ -8,16 +8,27 @@ def test_purchase_then_consume_on_hand():
|
|||
led = Ledger()
|
||||
led.purchase("2x4", 5)
|
||||
led.purchase("ply-3/4", 2)
|
||||
assert led.on_hand() == {"2x4": 5, "ply-3/4": 2}
|
||||
assert led.on_hand() == {("2x4", "spruce"): 5, ("ply-3/4", "spruce-ply"): 2}
|
||||
led.record_build("table", 1, consumed={"2x4": 2}, offcuts=[])
|
||||
assert led.on_hand()["2x4"] == 3
|
||||
assert led.on_hand()[("2x4", "spruce")] == 3
|
||||
|
||||
|
||||
def test_adjustment_corrects_count():
|
||||
led = Ledger()
|
||||
led.purchase("2x4", 5)
|
||||
led.adjust("2x4", -1, reason="broke one")
|
||||
assert led.on_hand()["2x4"] == 4
|
||||
assert led.on_hand()[("2x4", "spruce")] == 4
|
||||
|
||||
|
||||
def test_inventory_is_species_aware():
|
||||
led = Ledger()
|
||||
led.purchase("1x4", 3, material="oak")
|
||||
led.purchase("1x4", 2, material="spruce")
|
||||
oh = led.on_hand()
|
||||
assert oh[("1x4", "oak")] == 3 and oh[("1x4", "spruce")] == 2
|
||||
# consuming oak doesn't touch spruce
|
||||
led.record_build("x", 1, consumed={("1x4", "oak"): 1}, offcuts=[])
|
||||
assert led.on_hand()[("1x4", "oak")] == 2 and led.on_hand()[("1x4", "spruce")] == 2
|
||||
|
||||
|
||||
def test_record_build_keeps_and_discards_offcuts():
|
||||
|
|
@ -46,7 +57,7 @@ def test_plan_consumption_from_cutplan():
|
|||
s.place("2x4", 60) # leaves a reusable ~35" offcut on a 96" stick
|
||||
plan = build_cut_plan(s)
|
||||
consumed, offcuts = plan_consumption(plan)
|
||||
assert consumed.get("2x4") == 1
|
||||
assert consumed.get(("2x4", "spruce")) == 1
|
||||
assert offcuts and offcuts[0]["stock"] == "2x4" and offcuts[0]["length_in"] > 12
|
||||
|
||||
|
||||
|
|
@ -66,7 +77,7 @@ def test_ledger_save_load_roundtrip(tmp_path, monkeypatch):
|
|||
led.purchase("2x4", 3, price=3.98)
|
||||
led.save()
|
||||
again = Ledger.load()
|
||||
assert again.on_hand()["2x4"] == 3
|
||||
assert again.on_hand()[("2x4", "spruce")] == 3
|
||||
assert again.stats()["spent"] == round(3 * 3.98, 2)
|
||||
|
||||
|
||||
|
|
@ -91,3 +102,15 @@ def test_planner_consumes_offcuts_before_buying():
|
|||
assert plan.score["owned_count"] == 1
|
||||
owned = [sp for sp in plan.stock_pieces if sp.owned]
|
||||
assert owned and owned[0].placements # the 30" sits in the offcut
|
||||
|
||||
|
||||
def test_oak_project_not_satisfied_by_spruce_onhand():
|
||||
"""Codex #2: a generic spruce 1x4 on-hand must NOT satisfy an oak 1x4 cut."""
|
||||
led = Ledger()
|
||||
led.purchase("1x4", 5, material="spruce")
|
||||
s = Scene()
|
||||
s.place("1x4", 30)
|
||||
s.set_material("p1", "oak")
|
||||
plan = build_cut_plan(s, available=led.available_stock())
|
||||
assert plan.score["stock_count"] == 1 # must buy oak
|
||||
assert plan.score["owned_count"] == 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue