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