109 lines
3.9 KiB
Python
109 lines
3.9 KiB
Python
"""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
|