woodshop/tests/test_cutplan.py

403 lines
14 KiB
Python
Raw Permalink 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.

"""Phase 0 tests for the CutPlan model."""
import json
from woodshop.cutplan import CutPlan, ShopSettings, build_cut_plan, validate_cut_plan
from woodshop.scene import Scene
def test_lumber_plan_packs_and_validates():
s = Scene()
for _ in range(3):
s.place("2x4", 40)
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
assert len(sticks) == 2
assert sum(len(sp.placements) for sp in sticks) == 3
assert plan.score["stock_count"] == 2
assert validate_cut_plan(plan) == []
def test_kerf_prevents_two_48_in_one_stick():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
assert build_cut_plan(s).score["stock_count"] == 2
def test_tenon_extends_cut_item_length():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "tenon", face="end_b", depth_in=2)
item = build_cut_plan(s).items[0]
assert item.length_in == 26 and "tenon" in item.note
def test_plywood_plan_and_validate():
s = Scene()
s.place("ply-3/4", 40, width_in=20)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1 and len(sheets[0].placements) == 2
assert validate_cut_plan(plan) == []
def test_oversize_lumber_warns_and_is_unplaced():
s = Scene()
s.place("2x4", 120) # longer than a 96" stick
plan = build_cut_plan(s)
assert plan.unplaced and plan.warnings
assert validate_cut_plan(plan) == [] # flagged, so still valid
def test_stable_ids_present():
s = Scene()
s.place("2x4", 40)
plan = build_cut_plan(s)
assert all(it.id for it in plan.items)
assert all(sp.id for sp in plan.stock_pieces)
assert all(p.id for sp in plan.stock_pieces for p in sp.placements)
def test_json_roundtrip():
s = Scene()
s.place("2x4", 40)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.settings.kerf_in == plan.settings.kerf_in
assert [sp.id for sp in plan2.stock_pieces] == [sp.id for sp in plan.stock_pieces]
assert plan2.score["stock_count"] == plan.score["stock_count"]
assert validate_cut_plan(plan2) == []
def test_plywood_rotation_fits_panel():
s = Scene()
s.place("ply-3/4", 30, width_in=60) # 60" wide > 48" sheet — needs rotating
plan = build_cut_plan(s) # rotation allowed by default
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1
p = sheets[0].placements[0]
assert p.rotated and p.len_in == 60 and p.wid_in == 30
assert validate_cut_plan(plan) == []
def test_rotation_disabled_flags_unfit():
s = Scene()
s.place("ply-3/4", 30, width_in=60)
plan = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
assert plan.unplaced and plan.warnings
def test_best_cut_plan_is_no_worse():
from woodshop.cutplan import _plan_key, best_cut_plan
s = Scene()
for ln in (50, 46, 30, 30, 20):
s.place("2x4", ln)
best = best_cut_plan(s)
base = build_cut_plan(s, strategy="decreasing")
assert _plan_key(best) <= _plan_key(base)
assert best.strategy == "optimized"
assert validate_cut_plan(best) == []
def test_snap_and_fits():
from woodshop.cutplan import placement_fits, snap_x
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30) # both fit one stick
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements[0], stick.placements[1]
k = plan.settings.kerf_in
assert abs(snap_x(stick, p2, 31.0, k) - (p1.x_in + p1.len_in + k)) < 1e-6
p2.x_in = 0.0
assert not placement_fits(stick, p2, k) # now overlaps p1
p2.x_in = p1.len_in + k
assert placement_fits(stick, p2, k) # butted clear
def test_relocate_between_sticks():
from woodshop.cutplan import relocate
s = Scene()
for _ in range(3):
s.place("2x4", 60) # each needs its own stick
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
assert len(sticks) == 3
pid = sticks[2].placements[0].id
relocate(plan, pid, sticks[0].id, 0.0)
assert any(p.id == pid for p in sticks[0].placements)
assert all(p.id != pid for p in sticks[2].placements)
def test_rotate_placement_swaps_footprint():
from woodshop.cutplan import rotate_placement
s = Scene()
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
p = next(sp for sp in plan.stock_pieces if sp.is_sheet).placements[0]
L, W, rot = p.len_in, p.wid_in, p.rotated
rotate_placement(plan, p.id)
assert p.len_in == W and p.wid_in == L and p.rotated != rot
def test_kerf_gap_required_not_just_overlap():
from woodshop.cutplan import placement_fits
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements
k = plan.settings.kerf_in
p2.x_in = p1.len_in + 0.01 # closer than a kerf
assert not placement_fits(stick, p2, k)
p2.x_in = p1.len_in + k # exactly a kerf apart
assert placement_fits(stick, p2, k)
def test_validate_flags_wrong_stock_and_illegal_rotation():
from woodshop.cutplan import relocate, rotate_placement
s = Scene()
s.place("2x4", 24)
s.place("ply-3/4", 24, width_in=24)
plan = build_cut_plan(s)
lumber = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
sheet = next(sp for sp in plan.stock_pieces if sp.is_sheet)
relocate(plan, lumber.placements[0].id, sheet.id, 0.0, 0.0)
assert any("stock piece" in p for p in validate_cut_plan(plan))
plan2 = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
sh2 = next(sp for sp in plan2.stock_pieces if sp.is_sheet)
rotate_placement(plan2, sh2.placements[0].id)
assert any("rotation" in p for p in validate_cut_plan(plan2))
def test_recompute_updates_waste_after_move():
from woodshop.cutplan import recompute
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
stick.placements[1].x_in = 60.0 # leave a gap after p1
recompute(plan)
assert any(abs(w.x_in - 30) < 1.0 for w in stick.waste) # gap at ~30 now waste
def test_stable_hash_is_deterministic():
from woodshop.cutplan import _stable_hash
assert _stable_hash("ci1x") == _stable_hash("ci1x")
def test_reoptimize_preserves_locked_placement():
from woodshop.cutplan import reoptimize
s = Scene()
for ln in (40, 40, 40):
s.place("2x4", ln)
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
locked = sticks[-1].placements[0]
locked.locked = True
lx, lid = locked.x_in, locked.id
re = reoptimize(s, plan, "decreasing")
kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid]
assert kept and kept[0].locked and abs(kept[0].x_in - lx) < 1e-6
placed = {p.item_id for sp in re.stock_pieces for p in sp.placements}
assert {it.id for it in re.items} <= placed | set(re.unplaced) # nothing lost
assert validate_cut_plan(re) == []
def test_exact_no_worse_than_ffd():
s = Scene()
for ln in (50, 46, 40, 30, 30, 20):
s.place("2x4", ln)
ex = build_cut_plan(s, strategy="exact")
ffd = build_cut_plan(s, strategy="decreasing")
assert ex.score["stock_count"] <= ffd.score["stock_count"]
placed = {p.item_id for sp in ex.stock_pieces for p in sp.placements}
assert {it.id for it in ex.items} <= placed | set(ex.unplaced)
assert validate_cut_plan(ex) == []
def test_exact_handles_oversize():
s = Scene()
s.place("2x4", 40)
s.place("2x4", 120) # bigger than a stick
plan = build_cut_plan(s, strategy="exact")
assert plan.unplaced and plan.warnings
assert validate_cut_plan(plan) == []
def test_guillotine_packs_and_validates():
s = Scene()
for _ in range(4):
s.place("ply-3/4", 30, width_in=20)
g = build_cut_plan(s, strategy="guillotine")
sheets = [sp for sp in g.stock_pieces if sp.is_sheet]
assert sheets and sum(len(sp.placements) for sp in sheets) == 4
assert validate_cut_plan(g) == []
def test_guillotine_oversize_panel_unplaced():
s = Scene()
s.place("ply-3/4", 200, width_in=200) # bigger than a whole sheet
g = build_cut_plan(s, strategy="guillotine")
assert g.unplaced and g.warnings
assert validate_cut_plan(g) == []
def test_best_of_n_no_worse():
from woodshop.cutplan import _plan_key, best_cut_plan
s = Scene()
for ln in (50, 46, 40, 30, 30, 20):
s.place("2x4", ln)
best = best_cut_plan(s, attempts=50)
base = build_cut_plan(s, strategy="decreasing")
assert _plan_key(best) <= _plan_key(base)
assert validate_cut_plan(best) == []
def test_reoptimize_plywood_keeps_unlocked_on_locked_sheet():
"""Locking one panel must NOT push the other onto a fresh sheet when they
still share one sheet (Codex finding #1)."""
from woodshop.cutplan import reoptimize
s = Scene()
s.place("ply-3/4", 40, width_in=20)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s, strategy="guillotine")
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1
locked = sheets[0].placements[0]
locked.locked = True
lx, ly, lid = locked.x_in, locked.y_in, locked.id
re = reoptimize(s, plan, "guillotine")
re_sheets = [sp for sp in re.stock_pieces if sp.is_sheet]
assert len(re_sheets) == 1 # still one sheet, not split
placed = {p.item_id for sp in re.stock_pieces for p in sp.placements}
assert {it.id for it in re.items} <= placed | set(re.unplaced)
kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid]
assert kept and kept[0].locked
assert abs(kept[0].x_in - lx) < 1e-6 and abs(kept[0].y_in - ly) < 1e-6
assert validate_cut_plan(re) == []
def test_custom_settings_kerf():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
# zero kerf -> both 48" fit in one 96" stick
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
def test_raw_part_has_no_allowance():
s = Scene()
s.place("2x4", 24)
it = build_cut_plan(s).items[0]
assert it.length_in == 24 and it.final_len == 24 and not it.has_allowance
def test_sanded_lumber_cut_oversize_in_length_only():
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert abs(it.length_in - 24.0625) < 0.01 # length padded ~1/16"
assert it.final_len == 24.0
assert it.width_in == it.final_wid # lumber width NOT padded
assert it.has_allowance
def test_sanded_plywood_pads_width_too():
s = Scene()
s.place("ply-3/4", 24, width_in=12)
s.set_finish("p1", "paint")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert it.length_in > it.final_len and it.width_in > it.final_wid
def test_allowance_roundtrips_in_plan_json():
import json
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.items[0].final_len == plan.items[0].final_len
def test_batch_replicates_cut_items_with_units():
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=3)
assert len(plan.items) == 6 # 2 parts × 3 units
assert {it.unit for it in plan.items} == {1, 2, 3}
assert validate_cut_plan(plan) == []
def test_batch_shares_offcuts_across_units():
s = Scene()
s.place("2x4", 30) # 3 fit per 96" stick
one = build_cut_plan(s, quantity=1).score["stock_count"]
three = build_cut_plan(s, quantity=3).score["stock_count"]
assert one == 1 and three == 1 # 3×30" still one stick, not three
def test_batch_estimate_per_unit():
from woodshop import estimate as E
s = Scene()
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=4)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=E.EstimateRates(), quantity=4)
assert est.quantity == 4
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
assert abs(est.per_unit_price - est.price / 4) < 0.01
def test_locked_reoptimize_preserves_owned_offcut():
"""Codex #1: a locked offcut must stay owned through reoptimize, not become bought."""
from woodshop.cutplan import reoptimize
from woodshop.inventory import Piece
s = Scene()
s.place("2x4", 30)
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
is_sheet=False, is_offcut=True)
plan = build_cut_plan(s, available=[offcut])
assert plan.score["stock_count"] == 0
owned_piece = next(sp for sp in plan.stock_pieces if sp.owned)
owned_piece.placements[0].locked = True
re = reoptimize(s, plan, "decreasing")
assert re.score["stock_count"] == 0 # still bought nothing
assert re.score["owned_count"] == 1
def test_validate_flags_cross_species():
"""Codex #3: an oak cut on a spruce stick is invalid."""
from woodshop.cutplan import relocate
s = Scene()
s.place("1x4", 24) # spruce
s.place("1x4", 24)
s.set_material("p2", "oak")
plan = build_cut_plan(s) # two species → two pieces
spruce_sp = next(sp for sp in plan.stock_pieces if sp.material == "spruce")
oak_sp = next(sp for sp in plan.stock_pieces if sp.material == "oak")
relocate(plan, oak_sp.placements[0].id, spruce_sp.id, 40.0)
assert any("spruce stock piece" in p for p in validate_cut_plan(plan))
def test_yield_excludes_owned_offcuts():
"""Codex #4: an owned-only plan shouldn't report 0% (div by bought=0) misleadingly."""
from woodshop.inventory import Piece
s = Scene()
s.place("2x4", 30)
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
is_sheet=False, is_offcut=True)
plan = build_cut_plan(s, available=[offcut])
assert plan.score["yield_pct"] == 0.0 # nothing bought → no bought-yield
assert plan.score["stock_count"] == 0