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:
rob 2026-05-30 20:01:23 -03:00
parent 36d02fcb73
commit 01c4dee0bc
7 changed files with 167 additions and 46 deletions

View File

@ -511,12 +511,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
def _score(stock_pieces, s, strategy, warnings) -> dict: 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 reusable = 0
bought = [sp for sp in stock_pieces if not sp.owned] bought = [sp for sp in stock_pieces if not sp.owned]
for sp in stock_pieces: for sp in stock_pieces:
used = sum(p.len_in * p.wid_in for p in sp.placements) 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 sp.is_sheet:
if not sp.owned: if not sp.owned:
bought_area += sp.length_in * sp.width_in 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), "waste_area": round(waste_area, 1),
"reusable_offcuts": reusable, "reusable_offcuts": reusable,
"reusable_in": round(reusable_in, 1), "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), "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( seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece(
id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet, id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet,
length_in=sp.length_in, width_in=sp.width_in, material=sp.material, 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, 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) len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True)
for p in kept]) for p in kept])
@ -773,6 +775,8 @@ def validate_cut_plan(plan: CutPlan) -> list:
it = items.get(p.item_id) it = items.get(p.item_id)
if it and it.stock != sp.stock: if it and it.stock != sp.stock:
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece") 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: if p.rotated and not rot_ok:
problems.append(f"{p.id} is rotated but rotation isn't allowed") problems.append(f"{p.id} is rotated but rotation isn't allowed")
ps = sp.placements ps = sp.placements

View File

@ -144,7 +144,7 @@ class BomWindow(QDialog):
self._rebuild_base() self._rebuild_base()
def _mark_purchased(self) -> None: 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 getattr(sp, "owned", False))
if not bought: if not bought:
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.") QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
@ -153,16 +153,19 @@ class BomWindow(QDialog):
if not dlg.exec(): if not dlg.exec():
return return
rows, save = dlg.result() rows, save = dlg.result()
for stock, qty, price in rows: mults = prices_mod.load_material_multipliers()
self._ledger.purchase(stock, qty, price=price) for stock, material, qty, price in rows:
self._ledger.purchase(stock, qty, material=material, price=price)
if save: 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() self._ledger.save()
if save: if save:
prices_mod.save_prices(self._prices) prices_mod.save_prices(self._prices)
self._cost_te.setPlainText(self._cost_text()) self._cost_te.setPlainText(self._cost_text())
QMessageBox.information(self, "Added to inventory", 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: def _record_build(self) -> None:
consumed, offcuts = plan_consumption(self._plan) consumed, offcuts = plan_consumption(self._plan)
@ -261,8 +264,8 @@ class BomWindow(QDialog):
it = plan.item(iid) it = plan.item(iid)
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}") lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
lines += ["", "Yield (used / bought):"] lines += ["", "Yield (used / bought):"]
for stock in sorted({sp.stock for sp in plan.stock_pieces}): for stock in sorted({sp.stock for sp in bought}): # bought only — matches score
sps = [sp for sp in plan.stock_pieces if sp.stock == stock] sps = [sp for sp in bought if sp.stock == stock]
sheet = sps[0].is_sheet sheet = sps[0].is_sheet
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements) 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) cap = sum(sp.length_in * sp.width_in for sp in sps)
@ -500,7 +503,8 @@ class BomWindow(QDialog):
self._set_plan(best) self._set_plan(best)
self._status.setText("✓ optimized around locked pieces") self._status.setText("✓ optimized around locked pieces")
else: 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") self._status.setText("✓ optimized")
def _best_of_n(self) -> None: def _best_of_n(self) -> None:
@ -513,7 +517,8 @@ class BomWindow(QDialog):
self._set_plan(best) self._set_plan(best)
self._status.setText("✓ best of 100 around locked pieces") self._status.setText("✓ best of 100 around locked pieces")
else: 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") self._status.setText("✓ best of 100 attempts")
def _next_arrangement(self) -> None: def _next_arrangement(self) -> None:
@ -521,7 +526,8 @@ class BomWindow(QDialog):
self._order = (self._order + 1) % len(STRATEGIES) self._order = (self._order + 1) % len(STRATEGIES)
st = STRATEGIES[self._order] st = STRATEGIES[self._order]
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks() 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) self._set_plan(plan)
def _draw_layout(self) -> None: def _draw_layout(self) -> None:
@ -600,12 +606,20 @@ class BomWindow(QDialog):
if target is None: if target is None:
target = sp_cur target = sp_cur
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc. # 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: if item_stock != target.stock:
self._revert(plan, item.pid, home) self._revert(plan, item.pid, home)
self._status.setText(f"{item_stock} can't go on {target.stock} — reverted") self._status.setText(f"{item_stock} can't go on {target.stock} — reverted")
recompute(plan); self._refresh_all() recompute(plan); self._refresh_all()
return 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) row_y0 = self._row_of(target.id)
x_in = max(item.pos().x() / px, 0.0) 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 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): def __init__(self, bought, prices, parent=None):
super().__init__(parent) super().__init__(parent)
from ..lumber import default_material
self.setWindowTitle("Mark purchased") self.setWindowTitle("Mark purchased")
self.resize(360, 320) self.resize(380, 320)
mults = prices_mod.load_material_multipliers()
v = QVBoxLayout(self) v = QVBoxLayout(self)
v.addWidget(QLabel("Add these to your shop inventory?")) v.addWidget(QLabel("Add these to your shop inventory?"))
rows = sorted(bought.items()) rows = sorted(bought.items())
@ -812,16 +828,18 @@ class PurchaseDialog(QDialog):
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"]) self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._spins = [] self._spins = []
for r, (stock, qty) in enumerate(rows): for r, ((stock, material), qty) in enumerate(rows):
name = QTableWidgetItem(stock) label = (f"{material} {stock}" if material and material != default_material(stock)
else stock)
name = QTableWidgetItem(label)
name.setFlags(name.flags() & ~Qt.ItemIsEditable) name.setFlags(name.flags() & ~Qt.ItemIsEditable)
self._table.setItem(r, 0, name) self._table.setItem(r, 0, name)
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty)) q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2) 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, 1, q)
self._table.setCellWidget(r, 2, p) self._table.setCellWidget(r, 2, p)
self._spins.append((stock, q, p)) self._spins.append((stock, material, q, p))
v.addWidget(self._table) v.addWidget(self._table)
self._save = QCheckBox("Save these prices to my price book") self._save = QCheckBox("Save these prices to my price book")
v.addWidget(self._save) v.addWidget(self._save)
@ -830,8 +848,8 @@ class PurchaseDialog(QDialog):
v.addWidget(bb) v.addWidget(bb)
def result(self): def result(self):
rows = [(stock, q.value(), round(p.value(), 2)) rows = [(stock, material, q.value(), round(p.value(), 2))
for stock, q, p in self._spins if q.value() > 0] for stock, material, q, p in self._spins if q.value() > 0]
return rows, self._save.isChecked() return rows, self._save.isChecked()

View File

@ -46,10 +46,12 @@ class InventoryWindow(QDialog):
oh = self._ledger.on_hand() oh = self._ledger.on_hand()
if not oh: if not oh:
return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)" return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)"
from ..lumber import default_material
lines = ["ON-HAND STOCK", ""] 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" 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) return "\n".join(lines)
def _offcuts_text(self) -> str: def _offcuts_text(self) -> str:

View File

@ -25,7 +25,8 @@ from collections import Counter
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path 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 @dataclass
@ -51,7 +52,8 @@ def _data_path() -> Path:
def plan_consumption(plan) -> tuple[dict, list[dict]]: def plan_consumption(plan) -> tuple[dict, list[dict]]:
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts). """From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
Offcuts are the reusable waste regions; ids are assigned later by the ledger.""" 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 = [] offcuts = []
for sp in plan.stock_pieces: for sp in plan.stock_pieces:
for w in sp.waste: for w in sp.waste:
@ -97,16 +99,18 @@ class Ledger:
return f"b{n}" return f"b{n}"
# ----- primitives (each appends events) ----------------------------- # ----- 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: date: str = "", is_sheet: bool | None = None) -> None:
stock = normalize_stock(stock) stock = normalize_stock(stock)
sheet = is_plywood(stock) if is_sheet is None else is_sheet sheet = is_plywood(stock) if is_sheet is None else is_sheet
self._emit("purchase", stock=stock, qty=int(qty), is_sheet=sheet, self._emit("purchase", stock=stock, material=material or default_material(stock),
price=price, date=date) qty=int(qty), is_sheet=sheet, price=price, date=date)
def adjust(self, stock: str, delta: int, reason: str = "", date: str = "") -> None: def adjust(self, stock: str, delta: int, material: str = "", reason: str = "",
self._emit("adjustment", stock=normalize_stock(stock), delta=int(delta), date: str = "") -> None:
reason=reason, date=date) 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: def discard_offcut(self, offcut_id: str, fate: str, date: str = "") -> None:
self._emit("discard", offcut_id=offcut_id, fate=fate, date=date) self._emit("discard", offcut_id=offcut_id, fate=fate, date=date)
@ -122,10 +126,13 @@ class Ledger:
bin), "burned"/"trashed" ( discard), or "ignore" (don't track). Defaults bin), "burned"/"trashed" ( discard), or "ignore" (don't track). Defaults
to keeping every offcut. Returns the build id.""" to keeping every offcut. Returns the build id."""
build_id = self._next_build_id() build_id = self._next_build_id()
for stock, qty in consumed.items(): for key, qty in consumed.items():
if qty: if not qty:
self._emit("consume", stock=normalize_stock(stock), qty=int(qty), continue
build_id=build_id) 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) disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
for i, oc in enumerate(offcuts): for i, oc in enumerate(offcuts):
fate = disp[i] if i < len(disp) else "keep" fate = disp[i] if i < len(disp) else "keep"
@ -142,14 +149,19 @@ class Ledger:
# ----- derived state (fold the events) ------------------------------ # ----- derived state (fold the events) ------------------------------
def on_hand(self) -> dict: def on_hand(self) -> dict:
"""Full units on hand, keyed by (stock, material)."""
c = Counter() c = Counter()
def key(e):
return (e["stock"], e.get("material") or default_material(e["stock"]))
for e in self.events: for e in self.events:
if e["type"] == "purchase": if e["type"] == "purchase":
c[e["stock"]] += e["qty"] c[key(e)] += e["qty"]
elif e["type"] == "consume" and "stock" in e: elif e["type"] == "consume" and "stock" in e:
c[e["stock"]] -= e["qty"] c[key(e)] -= e["qty"]
elif e["type"] == "adjustment": 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} return {k: v for k, v in sorted(c.items()) if v}
def offcut_bin(self) -> list[Piece]: def offcut_bin(self) -> list[Piece]:
@ -172,15 +184,15 @@ class Ledger:
"""Everything the planner could consume: full on-hand units (given real """Everything the planner could consume: full on-hand units (given real
dimensions so they pack) + offcuts.""" dimensions so they pack) + offcuts."""
pieces = list(self.offcut_bin()) 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) sheet = is_plywood(stock)
if sheet: if sheet:
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
else: else:
L, W = stick_len_in, actual_section(stock)[1] L, W = stick_len_in, actual_section(stock)[1]
for i in range(qty): for i in range(qty):
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock, pieces.append(Piece(id=f"{stock}:{material}#{i + 1}", stock=stock,
length_in=L, width_in=W, is_sheet=sheet)) length_in=L, width_in=W, is_sheet=sheet, material=material))
return pieces return pieces
def builds(self) -> list[dict]: def builds(self) -> list[dict]:

View File

@ -156,3 +156,22 @@ def test_record_build_writes_ledger(tmp_path, monkeypatch):
w._ledger.save() w._ledger.save()
again = I.Ledger.load() again = I.Ledger.load()
assert again.builds() and again.stats()["units_built"] == 1 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

View File

@ -357,3 +357,46 @@ def test_batch_estimate_per_unit():
assert est.quantity == 4 assert est.quantity == 4
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01 assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
assert abs(est.per_unit_price - est.price / 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

View File

@ -8,16 +8,27 @@ def test_purchase_then_consume_on_hand():
led = Ledger() led = Ledger()
led.purchase("2x4", 5) led.purchase("2x4", 5)
led.purchase("ply-3/4", 2) 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=[]) 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(): def test_adjustment_corrects_count():
led = Ledger() led = Ledger()
led.purchase("2x4", 5) led.purchase("2x4", 5)
led.adjust("2x4", -1, reason="broke one") 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(): 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 s.place("2x4", 60) # leaves a reusable ~35" offcut on a 96" stick
plan = build_cut_plan(s) plan = build_cut_plan(s)
consumed, offcuts = plan_consumption(plan) 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 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.purchase("2x4", 3, price=3.98)
led.save() led.save()
again = Ledger.load() 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) 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 assert plan.score["owned_count"] == 1
owned = [sp for sp in plan.stock_pieces if sp.owned] owned = [sp for sp in plan.stock_pieces if sp.owned]
assert owned and owned[0].placements # the 30" sits in the offcut 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