From 30bfb3a9e0dab8dc0e4f93b3977c071d58094cda Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 16:40:15 -0300 Subject: [PATCH] Add project estimate: consumables, labor, and suggested selling price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cost tab now produces a full quote, not just material cost. - estimate.py: project_estimate() = materials (incl HST) + consumables (screws per butt joint, glue per M&T connection / dado / rabbet, finish $/sq ft of finished surface) + labor (editable minutes per operation — setup, cut, butt joint, assembly, sanding, and per-feature tenon/mortise/ hole/slot/dado/rabbet/chamfer — × counts from the scene/plan, × shop rate). - Selling price = MARGIN on total cost: price = total_cost / (1 - margin), labor counted as cost. A target price overrides margin and back-solves the implied margin. EstimateRates persisted to estimate.json. - Cost tab: live margin % spinbox + target $ field, "Edit rates…" (RatesEditDialog), existing "Edit prices…" / "Refresh from Kent…", Print. - All counts are deterministic (count_ops off scene.joints / connections / features / finishes); nothing guessed. - tests: op counts, screws/glue, labor scaling, margin formula, target back-solve, div-zero guard, rates roundtrip, format, and GUI cost-tab + margin/target controls. 163 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/estimate.py | 239 +++++++++++++++++++++++++++++++++ src/woodshop/gui/bom_window.py | 121 ++++++++++++++++- tests/test_bom_window.py | 29 +++- tests/test_estimate.py | 108 +++++++++++++++ 4 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 src/woodshop/estimate.py create mode 100644 tests/test_estimate.py diff --git a/src/woodshop/estimate.py b/src/woodshop/estimate.py new file mode 100644 index 0000000..e0deaf0 --- /dev/null +++ b/src/woodshop/estimate.py @@ -0,0 +1,239 @@ +"""Project estimate: materials + consumables + labor, then a suggested selling +price from an editable profit margin (or a target price that back-solves it). + +Like the rest of the shop packet this is a deterministic math layer: every +number is counted off the scene/CutPlan and multiplied by an editable rate. +Nothing is guessed — the rates below are sensible starting points, all editable +in the app (Cost tab → "Edit rates…") and persisted per machine. + +Selling-price model (Rob's choice): MARGIN on total cost. Labor at an editable +shop rate is a cost alongside materials and consumables; the margin is profit on +top: price = total_cost / (1 − margin). Set labor_rate to 0 to exclude labor. +A target price, if given, overrides margin and we report the implied margin. +""" +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from . import prices as prices_mod + +# Feature kinds that imply a glue-up not already captured as a Connection +# (tenon↔mortise mates are counted via scene.connections, so exclude them here). +GLUED_FEATURE_KINDS = {"dado", "rabbet", "slot"} + + +@dataclass +class EstimateRates: + # --- selling price --- + margin_pct: float = 30.0 # profit as a % of price; price = cost/(1-margin) + target_price: float = 0.0 # >0 overrides margin; back-solves implied margin + labor_rate_per_hr: float = 40.0 + # --- consumable unit costs ($) --- + screw_unit_cost: float = 0.10 + glue_cost_per_oz: float = 0.55 + finish_cost_per_sqft: float = 0.40 + # --- consumable quantities --- + screws_per_butt_joint: float = 2.0 + glue_oz_per_connection: float = 0.5 + glue_oz_per_glued_feature: float = 0.25 + # --- time (minutes) --- + setup_min: float = 30.0 + min_per_cut: float = 3.0 + min_per_butt_joint: float = 5.0 + min_per_connection: float = 8.0 + min_per_finish: float = 10.0 + min_per_feature: dict = field(default_factory=lambda: { + "tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0, + "dado": 6.0, "rabbet": 6.0, "chamfer": 4.0}) + + +def _config_path() -> Path: + base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop" + return base / "estimate.json" + + +def load_rates() -> EstimateRates: + rates = EstimateRates() + path = _config_path() + if path.exists(): + try: + saved = json.loads(path.read_text()) + base = asdict(rates) + for k, v in saved.items(): + if k == "min_per_feature" and isinstance(v, dict): + base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()}) + elif k in base and not isinstance(base[k], dict): + base[k] = float(v) + rates = EstimateRates(**base) + except (ValueError, OSError, TypeError): + pass + return rates + + +def save_rates(rates: EstimateRates) -> None: + path = _config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(rates), indent=2)) + + +def count_ops(scene, plan) -> dict: + """Deterministic operation counts off the scene + cut plan.""" + from collections import Counter + + feats = Counter(f.kind for p in scene.parts for f in p.features) + return { + "parts": len(scene.parts), + "cuts": len(plan.items), # one crosscut per cut item + "butt_joints": len(scene.joints), + "connections": len(scene.connections), + "glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS), + "finished_parts": sum(1 for p in scene.parts if p.finishes), + "features": dict(feats), + } + + +def _finished_sqft(scene) -> float: + total = 0.0 + for p in scene.parts: + if not p.finishes: + continue + t, w = p.section_in + L = p.length_in + total += 2 * (L * w + L * t + w * t) / 144.0 # all six faces, sq ft + return total + + +@dataclass +class Line: + label: str + detail: str + cost: float + + +@dataclass +class ProjectEstimate: + material: object # prices.CostEstimate + consumables: list # list[Line] + labor_lines: list # list[Line] (time breakdown, cost per group) + labor_minutes: float + rates: EstimateRates + + @property + def material_cost(self) -> float: + return self.material.total # includes HST — what you pay + + @property + def consumable_cost(self) -> float: + return round(sum(l.cost for l in self.consumables), 2) + + @property + def labor_cost(self) -> float: + return round(sum(l.cost for l in self.labor_lines), 2) + + @property + def total_cost(self) -> float: + return round(self.material_cost + self.consumable_cost + self.labor_cost, 2) + + @property + def effective_margin(self) -> float: + """Margin actually used: from target_price if set, else rates.margin_pct.""" + if self.rates.target_price and self.rates.target_price > 0: + price = self.rates.target_price + return round((price - self.total_cost) / price * 100, 1) if price else 0.0 + return self.rates.margin_pct + + @property + def price(self) -> float: + if self.rates.target_price and self.rates.target_price > 0: + return round(self.rates.target_price, 2) + m = min(max(self.rates.margin_pct, 0.0), 95.0) / 100.0 # guard /0 + return round(self.total_cost / (1 - m), 2) + + @property + def profit(self) -> float: + return round(self.price - self.total_cost, 2) + + +def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None, + hst: float = prices_mod.NB_HST) -> ProjectEstimate: + rates = rates or load_rates() + ops = count_ops(scene, plan) + material = prices_mod.estimate(plan, prices, hst=hst) + + # --- consumables --- + consumables = [] + screws = ops["butt_joints"] * rates.screws_per_butt_joint + if screws: + consumables.append(Line("Screws", f"{screws:g} × ${rates.screw_unit_cost:.2f}", + round(screws * rates.screw_unit_cost, 2))) + glue_oz = (ops["connections"] * rates.glue_oz_per_connection + + ops["glued_features"] * rates.glue_oz_per_glued_feature) + if glue_oz: + consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz", + round(glue_oz * rates.glue_cost_per_oz, 2))) + sqft = _finished_sqft(scene) + if sqft: + consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}", + round(sqft * rates.finish_cost_per_sqft, 2))) + + # --- labor (minutes -> cost) --- + def line(label, minutes, n_detail): + return Line(label, n_detail, round(minutes / 60.0 * rates.labor_rate_per_hr, 2)), minutes + + labor_lines, total_min = [], 0.0 + groups = [ + ("Setup / cleanup", rates.setup_min if ops["parts"] else 0.0, "fixed"), + ("Cutting to length", ops["cuts"] * rates.min_per_cut, f"{ops['cuts']} cut(s)"), + ("Butt joints", ops["butt_joints"] * rates.min_per_butt_joint, + f"{ops['butt_joints']} joint(s)"), + ("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection, + f"{ops['connections']} connection(s)"), + ("Sanding / finishing", ops["finished_parts"] * rates.min_per_finish, + f"{ops['finished_parts']} part(s)"), + ] + for kind, n in sorted(ops["features"].items()): + groups.append((f"Joinery: {kind}", n * rates.min_per_feature.get(kind, 5.0), + f"{n} × {kind}")) + for label, minutes, detail in groups: + if minutes > 0: + ln, m = line(label, minutes, detail) + labor_lines.append(ln) + total_min += m + + return ProjectEstimate(material=material, consumables=consumables, + labor_lines=labor_lines, labor_minutes=round(total_min, 1), + rates=rates) + + +def _money(v: float) -> str: + return f"${v:,.2f}" + + +def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str: + hrs = est.labor_minutes / 60.0 + lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)", + "editable estimate — verify before quoting", ""] + lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}") + for l in est.consumables: + lines.append(f" {l.label:<14} {l.detail:<22}{_money(l.cost):>10}") + if est.consumables: + lines.append(f" {'Consumables':<30}{_money(est.consumable_cost):>12}") + lines.append("") + for l in est.labor_lines: + lines.append(f" {l.label:<22} {l.detail:<16}{_money(l.cost):>10}") + lines.append(f" {'Labor (' + f'{hrs:.1f}h @ ' + _money(est.rates.labor_rate_per_hr) + '/h)':<30}" + f"{_money(est.labor_cost):>12}") + lines += [" " + "-" * 42, + f" {'TOTAL COST':<30}{_money(est.total_cost):>12}"] + if est.rates.target_price and est.rates.target_price > 0: + lines.append(f" {'Target price (margin ' + f'{est.effective_margin:g}%)':<30}" + f"{_money(est.price):>12}") + else: + lines.append(f" {'Margin (' + f'{est.rates.margin_pct:g}%)':<30}{_money(est.profit):>12}") + lines += [" " + "=" * 42, + f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}", + f" {'(profit ' + _money(est.profit) + ')':<30}"] + return "\n".join(lines) diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 969ce71..32dad1f 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -8,9 +8,10 @@ import subprocess from PySide6.QtCore import Qt, QThreadPool from PySide6.QtGui import QBrush, QColor, QFont, QPen from PySide6.QtPrintSupport import QPrintDialog, QPrinter -from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem, - QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, - QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton, +from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, + QGraphicsItem, QGraphicsRectItem, QGraphicsScene, + QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, + QHeaderView, QLabel, QMenu, QPushButton, QScrollArea, QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget) @@ -23,6 +24,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement from ..instructions import build_steps, format_steps, polish_prompt from ..jigs import explain_prompt, format_jigs, suggest_jigs from .. import prices as prices_mod +from .. import estimate as estimate_mod from .workers import run_async _PX = 7.0 # pixels per inch in the layout view @@ -78,6 +80,7 @@ class BomWindow(QDialog): self.pool = QThreadPool.globalInstance() self._prices = prices_mod.load_prices() + self._rates = estimate_mod.load_rates() self._cut_te = self._mono_te() self._shop_te = self._mono_te() tabs = QTabWidget() @@ -173,21 +176,72 @@ class BomWindow(QDialog): v = QVBoxLayout(w) self._cost_te = self._mono_te() v.addWidget(self._cost_te) + + # live price controls: margin % (or a target $ that overrides it) + pricing = QHBoxLayout() + pricing.addWidget(QLabel("Margin %")) + self._margin_spin = QDoubleSpinBox() + self._margin_spin.setRange(0.0, 95.0) + self._margin_spin.setValue(self._rates.margin_pct) + self._margin_spin.valueChanged.connect(self._on_margin_changed) + pricing.addWidget(self._margin_spin) + pricing.addSpacing(12) + pricing.addWidget(QLabel("or target $")) + self._target_spin = QDoubleSpinBox() + self._target_spin.setRange(0.0, 1_000_000.0) + self._target_spin.setDecimals(2) + self._target_spin.setSpecialValueText("—") # 0 shows as "—" (use margin) + self._target_spin.setValue(self._rates.target_price) + self._target_spin.valueChanged.connect(self._on_target_changed) + pricing.addWidget(self._target_spin) + pricing.addStretch() + v.addLayout(pricing) + row = QHBoxLayout() edit = QPushButton("Edit prices…") edit.clicked.connect(self._edit_prices) + rates = QPushButton("Edit rates…") + rates.setToolTip("Labor rate, time per operation, and consumable costs") + rates.clicked.connect(self._edit_rates) refresh = QPushButton("Refresh from Kent…") refresh.setToolTip("Best-effort fetch of current Kent NB prices " "(needs a headless browser; may need updating if Kent changes)") refresh.clicked.connect(self._refresh_prices) pr = QPushButton("Print…") pr.clicked.connect(lambda: self._print_text(self._cost_te)) - row.addWidget(edit); row.addWidget(refresh); row.addStretch(); row.addWidget(pr) + row.addWidget(edit); row.addWidget(rates); row.addWidget(refresh) + row.addStretch(); row.addWidget(pr) v.addLayout(row) return w def _cost_text(self) -> str: - return prices_mod.format_estimate(prices_mod.estimate(self._plan, self._prices)) + est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates) + return estimate_mod.format_estimate(est) + + def _on_margin_changed(self, value: float) -> None: + self._rates.margin_pct = value + if value and self._target_spin.value(): # margin wins; clear any target + self._target_spin.blockSignals(True) + self._target_spin.setValue(0.0) + self._target_spin.blockSignals(False) + self._rates.target_price = 0.0 + estimate_mod.save_rates(self._rates) + self._cost_te.setPlainText(self._cost_text()) + + def _on_target_changed(self, value: float) -> None: + self._rates.target_price = value + estimate_mod.save_rates(self._rates) + self._cost_te.setPlainText(self._cost_text()) + + def _edit_rates(self) -> None: + dlg = RatesEditDialog(self._rates, self) + if dlg.exec(): + self._rates = dlg.rates() + estimate_mod.save_rates(self._rates) + self._margin_spin.blockSignals(True) + self._margin_spin.setValue(self._rates.margin_pct) + self._margin_spin.blockSignals(False) + self._cost_te.setPlainText(self._cost_text()) def _edit_prices(self) -> None: dlg = PriceEditDialog(self._prices, self) @@ -543,3 +597,60 @@ class PriceEditDialog(QDialog): except (ValueError, AttributeError): continue # skip blanked / bad cells return out + + +class RatesEditDialog(QDialog): + """Edit labor rate, per-operation minutes, and consumable costs.""" + + # field -> (label, suffix, step) + _SCALARS = [ + ("labor_rate_per_hr", "Labor rate", " $/h", 1.0), + ("setup_min", "Setup / cleanup", " min", 1.0), + ("min_per_cut", "Time per cut", " min", 0.5), + ("min_per_butt_joint", "Time per butt joint", " min", 0.5), + ("min_per_connection", "Time per assembly (M&T)", " min", 0.5), + ("min_per_finish", "Time per part sanded", " min", 0.5), + ("screws_per_butt_joint", "Screws per butt joint", "", 1.0), + ("screw_unit_cost", "Screw cost", " $", 0.01), + ("glue_oz_per_connection", "Glue per assembly", " oz", 0.1), + ("glue_oz_per_glued_feature", "Glue per dado/rabbet", " oz", 0.1), + ("glue_cost_per_oz", "Glue cost", " $/oz", 0.05), + ("finish_cost_per_sqft", "Finish cost", " $/sq ft", 0.05), + ] + + def __init__(self, rates, parent=None): + super().__init__(parent) + self.setWindowTitle("Edit rates") + self.resize(380, 560) + self._rates = rates + self._spins = {} + self._feat_spins = {} + + outer = QVBoxLayout(self) + area = QScrollArea(); area.setWidgetResizable(True) + body = QWidget(); form = QFormLayout(body) + for field, label, suffix, step in self._SCALARS: + sp = QDoubleSpinBox() + sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix) + sp.setValue(float(getattr(rates, field))) + self._spins[field] = sp + form.addRow(label, sp) + form.addRow(QLabel("— Joinery time (minutes each) —")) + for kind, minutes in sorted(rates.min_per_feature.items()): + sp = QDoubleSpinBox() + sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min") + sp.setValue(float(minutes)) + self._feat_spins[kind] = sp + form.addRow(kind, sp) + area.setWidget(body) + outer.addWidget(area) + bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + bb.accepted.connect(self.accept); bb.rejected.connect(self.reject) + outer.addWidget(bb) + + def rates(self): + for field, sp in self._spins.items(): + setattr(self._rates, field, sp.value()) + for kind, sp in self._feat_spins.items(): + self._rates.min_per_feature[kind] = sp.value() + return self._rates diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index e802fe5..2792c69 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -65,7 +65,28 @@ def test_cost_tab_renders_estimate(tmp_path, monkeypatch): c.place("2x4", 40) w = BomWindow(c) text = w._cost_te.toPlainText() - assert "COST ESTIMATE" in text and "2x4" in text and "Total" in text + 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): @@ -74,11 +95,11 @@ def test_edit_prices_persists_and_updates(tmp_path, monkeypatch): c = Controller(str(tmp_path / "s.json")) c.place("2x4", 40) w = BomWindow(c) - w._prices["2x4"] = 9.99 # simulate an edit accepted from the dialog + 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 "$9.99" in w._cost_te.toPlainText() - assert P.load_prices()["2x4"] == 9.99 + 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): diff --git a/tests/test_estimate.py b/tests/test_estimate.py new file mode 100644 index 0000000..02025b4 --- /dev/null +++ b/tests/test_estimate.py @@ -0,0 +1,108 @@ +"""Tests for the project estimate (consumables + labor + selling price).""" +from woodshop import estimate as E +from woodshop import prices as P +from woodshop.cutplan import build_cut_plan +from woodshop.scene import Scene + + +def _table_scene(): + """A tiny table-ish scene: 4 legs + 4 aprons, joined, some joinery.""" + s = Scene() + for _ in range(4): + s.place("2x4", 28) # legs + for _ in range(4): + s.place("2x4", 24) # aprons + # join aprons to legs (butt joins) + for apron in ("p5", "p6", "p7", "p8"): + s.join(apron, "p1") + s.add_feature("p5", "tenon", face="end_b", depth_in=1) + s.add_feature("p1", "mortise", face="left") + return s + + +def test_counts_match_scene(): + s = _table_scene() + plan = build_cut_plan(s) + ops = E.count_ops(s, plan) + assert ops["parts"] == 8 + assert ops["butt_joints"] == 4 + assert ops["features"]["tenon"] == 1 and ops["features"]["mortise"] == 1 + + +def test_consumables_screws_and_glue(): + s = Scene() + s.place("2x4", 24) + s.place("2x4", 24) + s.join("p2", "p1") # 1 butt joint + plan = build_cut_plan(s) + rates = E.EstimateRates(screws_per_butt_joint=2, screw_unit_cost=0.10) + est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates) + screws = next(l for l in est.consumables if l.label == "Screws") + assert screws.cost == 0.20 # 2 screws × $0.10 + + +def test_labor_scales_with_rate_and_ops(): + s = Scene() + s.place("2x4", 24) + plan = build_cut_plan(s) + rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10, + min_per_butt_joint=0, min_per_connection=0, min_per_finish=0) + est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates) + # 1 cut × 10 min = 10 min = 1/6 h × $60 = $10 + assert est.labor_minutes == 10.0 + assert est.labor_cost == 10.00 + + +def test_margin_pricing_formula(): + s = Scene() + s.place("2x4", 24) + plan = build_cut_plan(s) + # zero everything except a known material cost, 0% HST, 50% margin + rates = E.EstimateRates(margin_pct=50.0, labor_rate_per_hr=0.0, setup_min=0, + screws_per_butt_joint=0, glue_oz_per_connection=0) + est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0) + assert est.total_cost == 100.0 + assert est.price == 200.0 # 100 / (1 - 0.5) + assert est.profit == 100.0 + + +def test_target_price_backsolves_margin(): + s = Scene() + s.place("2x4", 24) + plan = build_cut_plan(s) + rates = E.EstimateRates(target_price=250.0, labor_rate_per_hr=0.0, setup_min=0, + screws_per_butt_joint=0) + est = E.project_estimate(s, plan, prices={"2x4": 100.0}, rates=rates, hst=0.0) + assert est.total_cost == 100.0 + assert est.price == 250.0 + # implied margin = (250-100)/250 = 60% + assert est.effective_margin == 60.0 + + +def test_margin_guarded_against_div_zero(): + s = Scene() + s.place("2x4", 24) + plan = build_cut_plan(s) + rates = E.EstimateRates(margin_pct=100.0, labor_rate_per_hr=0.0, setup_min=0, + screws_per_butt_joint=0) + est = E.project_estimate(s, plan, prices={"2x4": 50.0}, rates=rates, hst=0.0) + assert est.price > est.total_cost # clamped to 95%, no ZeroDivisionError + + +def test_rates_save_load_roundtrip(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + r = E.EstimateRates(margin_pct=42.0, labor_rate_per_hr=55.0) + r.min_per_feature["tenon"] = 99.0 + E.save_rates(r) + loaded = E.load_rates() + assert loaded.margin_pct == 42.0 and loaded.labor_rate_per_hr == 55.0 + assert loaded.min_per_feature["tenon"] == 99.0 + + +def test_format_includes_all_sections(): + s = _table_scene() + s.finish("p1") + plan = build_cut_plan(s) + text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0})) + for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"): + assert token in text