woodshop/tests/test_estimate.py

109 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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