woodshop/tests/test_estimate.py

147 lines
5.3 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)
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
def test_finish_cost_varies_by_kind():
from woodshop.scene import Scene
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "paint")
plan = build_cut_plan(s)
rates = E.EstimateRates()
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
fin = next(l for l in est.consumables if l.label == "Finish")
sqft = E._part_sqft(s.get_part("p1"))
assert abs(fin.cost - round(sqft * rates.finish_cost_per_sqft["paint"], 2)) < 0.01
# paint costs more than clear for the same part
s.set_finish("p1", "clear")
est2 = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
fin2 = next(l for l in est2.consumables if l.label == "Finish")
assert fin2.cost < fin.cost
def test_raw_parts_have_no_finish_cost():
from woodshop.scene import Scene
s = Scene()
s.place("2x4", 24) # raw
plan = build_cut_plan(s)
est = E.project_estimate(s, plan, prices={"2x4": 4.0})
assert not any(l.label == "Finish" for l in est.consumables)
def test_finish_rates_dict_roundtrips(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
r = E.EstimateRates()
r.finish_cost_per_sqft["paint"] = 1.23
r.min_per_finish["paint"] = 99.0
E.save_rates(r)
loaded = E.load_rates()
assert loaded.finish_cost_per_sqft["paint"] == 1.23
assert loaded.min_per_finish["paint"] == 99.0