293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""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
|