Add cost estimate (Cost tab) with editable Kent NB price book
New shop-packet output: a printable cost estimate driven by the active CutPlan's buy-counts × a curated, editable price book (HST 15%). - prices.py: DEFAULT_PRICES seeded with real Kent (New Brunswick) shelf prices per buy-unit (lumber = 8' stick, plywood = 4x8 sheet); persisted to $XDG_CONFIG_HOME/woodshop/prices.json (defaults + saved overrides). estimate() -> CostEstimate (lines/subtotal/tax/total/missing); lumber price scales with stick length; unknown stock is flagged, never invented. - BOM window: Cost tab with "Edit prices…" (PriceEditDialog), "Refresh from Kent…", and Print. - fetch_kent_prices() + scripts/fetch_kent_prices.py: best-effort refresh. Kent renders prices client-side (not in HTML), so it tries a static parse then Playwright if installed — honest that it may need updating. - tests: estimate math, per-sheet plywood, stick-length scaling, missing-price flagging, save/load roundtrip, corrupt-file fallback, JSON-LD parse, cost tab render + price edit persistence. 153 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d80be4e7f
commit
067ec0ea46
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Best-effort refresh of WoodShop's lumber price book from Kent (kent.ca, NB).
|
||||
|
||||
Kent renders prices in the browser (the price is not in the page HTML), so a
|
||||
plain HTTP fetch usually finds nothing. For reliable results install Playwright:
|
||||
|
||||
pip install playwright && playwright install chromium
|
||||
|
||||
Then run:
|
||||
|
||||
python scripts/fetch_kent_prices.py # show what it can read
|
||||
python scripts/fetch_kent_prices.py --write # also save to the price book
|
||||
|
||||
This is intentionally honest: if Kent changes their site this may stop working
|
||||
and you'll need to update KENT_URLS / the parser in src/woodshop/prices.py, or
|
||||
just edit prices by hand in the app (Cost tab → "Edit prices…").
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from woodshop import prices as P
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Refresh WoodShop prices from Kent NB.")
|
||||
ap.add_argument("--write", action="store_true", help="Save fetched prices to the price book")
|
||||
ap.add_argument("--no-browser", action="store_true",
|
||||
help="Skip the Playwright render (static HTTP only — likely finds nothing)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
print(f"Fetching {len(P.KENT_URLS)} product(s) from Kent…")
|
||||
fetched = P.fetch_kent_prices(use_browser=not args.no_browser)
|
||||
|
||||
if not fetched:
|
||||
print("\n ⚠ Could not read any live prices.")
|
||||
print(" Kent injects prices client-side; install Playwright for reliable results:")
|
||||
print(" pip install playwright && playwright install chromium")
|
||||
print(" Or edit prices by hand in the app (Cost tab → Edit prices…).")
|
||||
return 1
|
||||
|
||||
print("\n Read these prices:")
|
||||
for stock, price in sorted(fetched.items()):
|
||||
print(f" {stock:<10} ${price:,.2f}")
|
||||
|
||||
missing = sorted(set(P.KENT_URLS) - set(fetched))
|
||||
if missing:
|
||||
print(f"\n (couldn't read: {', '.join(missing)})")
|
||||
|
||||
if args.write:
|
||||
book = P.load_prices()
|
||||
book.update(fetched)
|
||||
P.save_prices(book)
|
||||
print(f"\n ✓ saved to {P._config_path()}")
|
||||
else:
|
||||
print("\n (dry run — pass --write to save these to the price book)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -8,9 +8,11 @@ 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, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
|
||||
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
||||
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGraphicsItem, QGraphicsRectItem,
|
||||
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
|
||||
QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
||||
QVBoxLayout, QWidget)
|
||||
|
||||
from collections import Counter
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
|
|||
snap_x, _plan_key)
|
||||
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 .workers import run_async
|
||||
|
||||
_PX = 7.0 # pixels per inch in the layout view
|
||||
|
|
@ -74,11 +77,13 @@ class BomWindow(QDialog):
|
|||
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
|
||||
self._prices = prices_mod.load_prices()
|
||||
self._cut_te = self._mono_te()
|
||||
self._shop_te = self._mono_te()
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._print_wrap(self._cut_te), "Cut List")
|
||||
tabs.addTab(self._print_wrap(self._shop_te), "Shopping List")
|
||||
tabs.addTab(self._cost_tab(), "Cost")
|
||||
tabs.addTab(self._layout_tab(), "Cut Layout")
|
||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||
tabs.addTab(self._jigs_tab(), "Jigs")
|
||||
|
|
@ -97,6 +102,7 @@ class BomWindow(QDialog):
|
|||
self._shop_te.setPlainText(self._shop_text())
|
||||
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
|
||||
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
self._draw_layout()
|
||||
|
||||
# ----- text tabs ----------------------------------------------------
|
||||
|
|
@ -161,6 +167,58 @@ class BomWindow(QDialog):
|
|||
if QPrintDialog(printer, self).exec():
|
||||
te.print_(printer)
|
||||
|
||||
# ----- cost tab -----------------------------------------------------
|
||||
def _cost_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
self._cost_te = self._mono_te()
|
||||
v.addWidget(self._cost_te)
|
||||
row = QHBoxLayout()
|
||||
edit = QPushButton("Edit prices…")
|
||||
edit.clicked.connect(self._edit_prices)
|
||||
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)
|
||||
v.addLayout(row)
|
||||
return w
|
||||
|
||||
def _cost_text(self) -> str:
|
||||
return prices_mod.format_estimate(prices_mod.estimate(self._plan, self._prices))
|
||||
|
||||
def _edit_prices(self) -> None:
|
||||
dlg = PriceEditDialog(self._prices, self)
|
||||
if dlg.exec():
|
||||
self._prices = dlg.prices()
|
||||
prices_mod.save_prices(self._prices)
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
|
||||
def _refresh_prices(self) -> None:
|
||||
self._cost_te.append("\n fetching current prices from Kent…")
|
||||
|
||||
def work():
|
||||
return prices_mod.fetch_kent_prices()
|
||||
|
||||
def done(fetched):
|
||||
if fetched:
|
||||
self._prices.update(fetched)
|
||||
prices_mod.save_prices(self._prices)
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
self._cost_te.append(f"\n ✓ updated {len(fetched)} price(s) from Kent.")
|
||||
else:
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
self._cost_te.append("\n ⚠ Couldn't read live prices (Kent renders them "
|
||||
"in-browser). Install playwright or edit prices by hand.")
|
||||
|
||||
def failed(err):
|
||||
self._cost_te.setPlainText(self._cost_text())
|
||||
self._cost_te.append(f"\n ⚠ price refresh failed: {err}")
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||
|
||||
# ----- instructions tab --------------------------------------------
|
||||
def _instructions_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
|
|
@ -449,3 +507,39 @@ class BomWindow(QDialog):
|
|||
painter = QPainter(printer)
|
||||
self.scene.render(painter)
|
||||
painter.end()
|
||||
|
||||
|
||||
class PriceEditDialog(QDialog):
|
||||
"""Edit the per-unit price book (lumber = 8' stick, plywood = 4×8 sheet)."""
|
||||
|
||||
def __init__(self, prices: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit prices")
|
||||
self.resize(360, 460)
|
||||
v = QVBoxLayout(self)
|
||||
v.addWidget(QLabel("Price per stick (8') / sheet (4×8), in CAD.\n"
|
||||
"Seeded from Kent NB — edit as needed."))
|
||||
rows = sorted(prices.items())
|
||||
self._table = QTableWidget(len(rows), 2)
|
||||
self._table.setHorizontalHeaderLabels(["Stock", "Price $"])
|
||||
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
for r, (stock, price) in enumerate(rows):
|
||||
name = QTableWidgetItem(stock)
|
||||
name.setFlags(name.flags() & ~Qt.ItemIsEditable) # stock names are fixed
|
||||
self._table.setItem(r, 0, name)
|
||||
self._table.setItem(r, 1, QTableWidgetItem(f"{float(price):.2f}"))
|
||||
v.addWidget(self._table)
|
||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
bb.accepted.connect(self.accept)
|
||||
bb.rejected.connect(self.reject)
|
||||
v.addWidget(bb)
|
||||
|
||||
def prices(self) -> dict:
|
||||
out = {}
|
||||
for r in range(self._table.rowCount()):
|
||||
stock = self._table.item(r, 0).text()
|
||||
try:
|
||||
out[stock] = round(float(self._table.item(r, 1).text()), 2)
|
||||
except (ValueError, AttributeError):
|
||||
continue # skip blanked / bad cells
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
"""Cost estimation for a cut plan, from a curated, editable price book.
|
||||
|
||||
Prices are CURATED, not scraped live. Retailer sites (Kent, Home Depot Canada)
|
||||
render prices with JavaScript after the page loads, so a plain HTTP fetch can't
|
||||
read them — the price is simply not in the HTML. The defaults below are real
|
||||
Kent (New Brunswick) shelf prices captured 2026-05; a few are reasonable
|
||||
estimates where a price wasn't confirmed. They are EDITABLE in the app (Cost
|
||||
tab → "Edit prices…") and can be refreshed with scripts/fetch_kent_prices.py.
|
||||
|
||||
Design note (matches the rest of the shop packet): the math is deterministic and
|
||||
inspectable — counts come from the CutPlan, prices from this table, tax is a
|
||||
flat rate. Nothing here guesses; unknown stock is flagged, never invented.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .lumber import normalize_stock
|
||||
|
||||
NB_HST = 0.15 # New Brunswick HST
|
||||
STD_STICK_IN = 96.0 # the stick length the lumber prices below assume (8')
|
||||
|
||||
# stock -> price for ONE standard buy unit:
|
||||
# lumber -> an 8' (96") stick
|
||||
# plywood -> a 4'×8' sheet
|
||||
# Source: Kent.ca, New Brunswick, captured 2026-05. ✓ = confirmed from a listing;
|
||||
# the rest are reasonable estimates — EDIT FREELY (they persist per machine).
|
||||
DEFAULT_PRICES: dict[str, float] = {
|
||||
"1x2": 2.49, "1x3": 3.29, "1x4": 4.79, "1x6": 6.98, "1x8": 10.98,
|
||||
"1x10": 14.98, "1x12": 18.98,
|
||||
"2x2": 2.79, "2x3": 2.98, "2x4": 3.98, # ✓ 2x4x8 stud $3.98
|
||||
"2x6": 6.98, "2x8": 10.98, "2x10": 15.98, "2x12": 19.98,
|
||||
"4x4": 12.98, "4x6": 24.98, "6x6": 38.98,
|
||||
"ply-1/8": 19.99, "ply-1/4": 24.99, # ✓ 1/4 4x8 $24.99
|
||||
"ply-3/8": 32.99, "ply-1/2": 41.99, "ply-5/8": 52.99,
|
||||
"ply-3/4": 63.98, # ✓ 3/4 4x8 spruce $63.98
|
||||
}
|
||||
|
||||
# Kent product pages for the refresh script (stock -> URL). Confirmed-real URLs.
|
||||
KENT_URLS: dict[str, str] = {
|
||||
"2x4": "https://kent.ca/en/2-x-4-x-8-spf-stud-kiln-dried-1016318",
|
||||
"ply-1/4": "https://kent.ca/en/1-2-x-4-x-8-12-5mm-spruce-plywood-standard-1015823",
|
||||
"ply-1/2": "https://kent.ca/en/1-2-x-4-x-8-12-5mm-spruce-plywood-standard-1015823",
|
||||
"ply-3/4": "https://kent.ca/en/3-4-x-4-x-8-18-5mm-spruce-plywood-standard-1015824",
|
||||
}
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser() / "woodshop"
|
||||
return base / "prices.json"
|
||||
|
||||
|
||||
def load_prices() -> dict[str, float]:
|
||||
"""The user's price book (defaults merged with any saved overrides)."""
|
||||
prices = dict(DEFAULT_PRICES)
|
||||
path = _config_path()
|
||||
if path.exists():
|
||||
try:
|
||||
saved = json.loads(path.read_text())
|
||||
prices.update({normalize_stock(k): float(v) for k, v in saved.items()})
|
||||
except (ValueError, OSError):
|
||||
pass # corrupt file -> fall back to defaults
|
||||
return prices
|
||||
|
||||
|
||||
def save_prices(prices: dict[str, float]) -> None:
|
||||
path = _config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({k: round(float(v), 2) for k, v in sorted(prices.items())},
|
||||
indent=2))
|
||||
|
||||
|
||||
def unit_label(stock: str) -> str:
|
||||
return "sheet (4×8)" if normalize_stock(stock).startswith("ply-") else "stick (8')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostLine:
|
||||
stock: str
|
||||
qty: int
|
||||
unit_price: float | None # None = no price on file
|
||||
unit_label: str
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return round((self.unit_price or 0.0) * self.qty, 2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CostEstimate:
|
||||
lines: list[CostLine] = field(default_factory=list)
|
||||
hst: float = NB_HST
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return round(sum(ln.total for ln in self.lines), 2)
|
||||
|
||||
@property
|
||||
def tax(self) -> float:
|
||||
return round(self.subtotal * self.hst, 2)
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return round(self.subtotal + self.tax, 2)
|
||||
|
||||
@property
|
||||
def missing(self) -> list[str]:
|
||||
return [ln.stock for ln in self.lines if ln.unit_price is None]
|
||||
|
||||
|
||||
def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST) -> CostEstimate:
|
||||
"""Cost of buying the stock a CutPlan calls for. Lumber prices scale with the
|
||||
plan's stick length (priced per foot off an 8' stick); plywood is per sheet."""
|
||||
from collections import Counter
|
||||
|
||||
prices = prices if prices is not None else load_prices()
|
||||
stick_in = getattr(plan.settings, "stick_len_in", STD_STICK_IN) or STD_STICK_IN
|
||||
counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces)
|
||||
|
||||
lines = []
|
||||
for stock, qty in sorted(counts.items()):
|
||||
base = prices.get(stock)
|
||||
if base is None:
|
||||
unit_price = None
|
||||
elif stock.startswith("ply-"):
|
||||
unit_price = round(base, 2) # per sheet
|
||||
else:
|
||||
unit_price = round(base * (stick_in / STD_STICK_IN), 2) # per actual stick
|
||||
lines.append(CostLine(stock=stock, qty=qty, unit_price=unit_price,
|
||||
unit_label=unit_label(stock)))
|
||||
return CostEstimate(lines=lines, hst=hst)
|
||||
|
||||
|
||||
def _parse_price(html: str) -> float | None:
|
||||
"""Pull a price out of page HTML, trying structured data first, then text.
|
||||
Returns None if nothing convincing is found (Kent renders prices in-browser,
|
||||
so static HTML usually has none — that's expected)."""
|
||||
import re
|
||||
|
||||
# 1) schema.org JSON-LD offers.price
|
||||
for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>',
|
||||
html, re.S | re.I):
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
for node in (data if isinstance(data, list) else [data]):
|
||||
offers = (node or {}).get("offers") if isinstance(node, dict) else None
|
||||
for off in (offers if isinstance(offers, list) else [offers] if offers else []):
|
||||
price = isinstance(off, dict) and off.get("price")
|
||||
if price:
|
||||
try:
|
||||
return round(float(str(price).replace(",", "")), 2)
|
||||
except ValueError:
|
||||
pass
|
||||
# 2) common attributes / meta
|
||||
m = re.search(r'(?:itemprop="price"[^>]*content|data-price|"price")\s*[=:]\s*"?\$?'
|
||||
r'([0-9]+(?:\.[0-9]{2}))', html)
|
||||
if m:
|
||||
return round(float(m.group(1)), 2)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_kent_prices(urls: dict[str, str] | None = None,
|
||||
use_browser: bool = True) -> dict[str, float]:
|
||||
"""Best-effort refresh of prices from Kent product pages. Returns {stock: price}
|
||||
for whatever it could read — possibly empty. Tries a static HTTP parse first;
|
||||
if that finds nothing and Playwright is installed, renders the page (the only
|
||||
reliable way, since Kent injects the price client-side). NEVER raises for a
|
||||
missing price — it just omits that stock so callers keep their existing value.
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
urls = urls or KENT_URLS
|
||||
found: dict[str, float] = {}
|
||||
pending: dict[str, str] = {}
|
||||
|
||||
for stock, url in urls.items():
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
html = resp.read().decode("utf-8", "replace")
|
||||
except Exception:
|
||||
continue
|
||||
price = _parse_price(html)
|
||||
if price is not None:
|
||||
found[stock] = price
|
||||
else:
|
||||
pending[stock] = url
|
||||
|
||||
if pending and use_browser:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return found # no browser — return what we have
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
for stock, url in pending.items():
|
||||
try:
|
||||
page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
text = page.inner_text("body")
|
||||
import re
|
||||
m = re.search(r'\$\s*([0-9]+(?:\.[0-9]{2}))', text)
|
||||
if m:
|
||||
found[stock] = round(float(m.group(1)), 2)
|
||||
except Exception:
|
||||
continue
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def _money(v: float) -> str:
|
||||
return f"${v:,.2f}"
|
||||
|
||||
|
||||
def format_estimate(est: CostEstimate, region: str = "Kent NB") -> str:
|
||||
pct = f"{est.hst * 100:g}%"
|
||||
lines = [f"COST ESTIMATE ({region} · HST {pct})",
|
||||
"editable estimates — verify before buying", ""]
|
||||
if not est.lines:
|
||||
lines.append(" (nothing to buy yet)")
|
||||
return "\n".join(lines)
|
||||
for ln in est.lines:
|
||||
price = _money(ln.unit_price) if ln.unit_price is not None else " — "
|
||||
total = _money(ln.total) if ln.unit_price is not None else " — "
|
||||
lines.append(f" {ln.stock:<9} {ln.qty:>2} × {ln.unit_label:<12} "
|
||||
f"{price:>9} {total:>10}")
|
||||
lines += [" " + "-" * 46,
|
||||
f" {'Subtotal':<38}{_money(est.subtotal):>10}",
|
||||
f" {'HST (' + pct + ')':<38}{_money(est.tax):>10}",
|
||||
f" {'Total':<38}{_money(est.total):>10}"]
|
||||
if est.missing:
|
||||
lines += ["", " ⚠ No price on file for: " + ", ".join(est.missing)
|
||||
+ " (Edit prices… to include)"]
|
||||
return "\n".join(lines)
|
||||
|
|
@ -58,6 +58,29 @@ def test_best_of_n_button_keeps_valid_plan(tmp_path):
|
|||
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 "COST ESTIMATE" in text and "2x4" in text and "Total" in text
|
||||
|
||||
|
||||
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"] = 9.99 # 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
|
||||
|
||||
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
"""Tests for the cost-estimate price book (deterministic math; no network)."""
|
||||
from woodshop import prices as P
|
||||
from woodshop.cutplan import ShopSettings, build_cut_plan
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_estimate_sums_sticks_and_applies_hst():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
s.place("2x4", 40) # 3 × 40" -> 2 sticks
|
||||
plan = build_cut_plan(s)
|
||||
est = P.estimate(plan, {"2x4": 4.00}, hst=0.15)
|
||||
line = next(ln for ln in est.lines if ln.stock == "2x4")
|
||||
assert line.qty == 2 and line.unit_price == 4.00 and line.total == 8.00
|
||||
assert est.subtotal == 8.00
|
||||
assert est.tax == 1.20
|
||||
assert est.total == 9.20
|
||||
|
||||
|
||||
def test_plywood_priced_per_sheet():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
s.place("ply-3/4", 40, width_in=20) # both fit one sheet
|
||||
plan = build_cut_plan(s)
|
||||
est = P.estimate(plan, {"ply-3/4": 63.98}, hst=0.0)
|
||||
line = next(ln for ln in est.lines if ln.stock == "ply-3/4")
|
||||
assert line.qty == 1 and line.unit_price == 63.98 and est.total == 63.98
|
||||
|
||||
|
||||
def test_lumber_price_scales_with_stick_length():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s, settings=ShopSettings(stick_len_in=192)) # 16' sticks
|
||||
est = P.estimate(plan, {"2x4": 4.00}) # priced per 8' stick
|
||||
line = next(ln for ln in est.lines if ln.stock == "2x4")
|
||||
assert line.unit_price == 8.00 # 192/96 × $4
|
||||
|
||||
|
||||
def test_missing_price_is_flagged_not_invented():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s)
|
||||
est = P.estimate(plan, {}) # empty book
|
||||
assert est.missing == ["2x4"]
|
||||
assert est.subtotal == 0.0
|
||||
assert "No price on file" in P.format_estimate(est)
|
||||
|
||||
|
||||
def test_save_and_load_roundtrip(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
P.save_prices({"2x4": 3.49, "ply-3/4": 59.99})
|
||||
loaded = P.load_prices()
|
||||
assert loaded["2x4"] == 3.49 and loaded["ply-3/4"] == 59.99
|
||||
# untouched stocks still come from defaults
|
||||
assert loaded["2x6"] == P.DEFAULT_PRICES["2x6"]
|
||||
|
||||
|
||||
def test_corrupt_price_file_falls_back_to_defaults(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
path = P._config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{ not json")
|
||||
assert P.load_prices()["2x4"] == P.DEFAULT_PRICES["2x4"]
|
||||
|
||||
|
||||
def test_format_estimate_empty():
|
||||
assert "nothing to buy" in P.format_estimate(P.estimate(build_cut_plan(Scene())))
|
||||
|
||||
|
||||
def test_parse_price_reads_json_ld():
|
||||
html = ('<script type="application/ld+json">'
|
||||
'{"@type":"Product","offers":{"@type":"Offer","price":"3.98"}}</script>')
|
||||
assert P._parse_price(html) == 3.98
|
||||
|
||||
|
||||
def test_parse_price_none_when_absent():
|
||||
assert P._parse_price("<html><body>no price here</body></html>") is None
|
||||
Loading…
Reference in New Issue