feat(review): #90 wood primary category + derived thickness band

This commit is contained in:
Ty
2026-09-21 02:41:20 -07:00
parent e1b928ccbd
commit 0329052d37
13 changed files with 719 additions and 42 deletions
+66
View File
@@ -33,6 +33,23 @@ def _seed_taxonomy(conn):
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
# live Woo attribute, because the derivation parses them.
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'),
(348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'),
(350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'),
(352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
"VALUES (?, 2, ?, ?, ?)",
(tid, name, slug, tid),
)
conn.commit()
@pytest.fixture
@@ -103,6 +120,55 @@ def test_create_and_fetch(client, db):
assert body[key] == payload[key]
def test_create_derives_thickness_band(client, db):
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
the UI has no band control, so the server's derivation is authoritative.
"""
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"thickness_term_id": 1,
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
def test_update_rebands_when_thickness_changes(client, db):
"""A thickness edit moves the slab to the band the new measurement rounds into."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
def test_update_ignores_client_supplied_band(client, db):
"""A stale client band cannot pin the slab to the wrong band."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
+94
View File
@@ -0,0 +1,94 @@
"""Thickness band resolution (METADATA-15 / QUALITY-95).
The owner never picks the Woo thickness band in the UI — the server derives it
from the measured thickness_in against the live synced bands. These tests pin the
derivation on the real live band names (from the UAT taxonomy) and on the
quarter-inch slug fallback.
"""
import pytest
from backend.thickness_band import (
parse_inch_token,
resolve_thickness_band_id,
term_bounds,
)
# The live UAT Thickness attribute (pa_thk) as synced: name, slug.
LIVE_TERMS = [
{"id": 347, "name": "Under 1/2\"", "slug": "xthin"},
{"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"},
{"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"},
{"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"},
{"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"},
{"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"},
{"id": 353, "name": "Over 3\"", "slug": "12qtr"},
]
@pytest.mark.parametrize(
"text,expected",
[
("1 1/2", 1.5),
("3/4", 0.75),
("2", 2.0),
("1.75", 1.75),
("2\"", 2.0),
("1 1/2\"", 1.5),
],
)
def test_parse_inch_token(text, expected):
assert parse_inch_token(text) == pytest.approx(expected)
def test_parse_inch_token_rejects_junk():
assert parse_inch_token("") is None
assert parse_inch_token(" ") is None
def test_term_bounds_from_live_names():
assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5)
assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75)
assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5)
assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf"))
def test_term_bounds_falls_back_to_qtr_slug():
assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5)
assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf"))
assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5)
@pytest.mark.parametrize(
"thickness,expected_id",
[
(0.25, 347), # Under 1/2"
(0.5, 348), # boundary rounds UP into 1/2"-3/4", not down
(0.625, 348),
(0.75, 349), # boundary rounds UP
(0.875, 349),
(1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band
(1.25, 350),
(1.5, 351),
(1.75, 351), # the UAT sample thickness
(2.0, 352),
(2.75, 352),
(3.0, 353),
(4.0, 353),
],
)
def test_resolve_rounds_up_on_boundary(thickness, expected_id):
assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id
def test_resolve_handles_missing_inputs():
assert resolve_thickness_band_id(None, LIVE_TERMS) is None
assert resolve_thickness_band_id(-1, LIVE_TERMS) is None
assert resolve_thickness_band_id(1.0, []) is None
# A term with no parseable name/slug is skipped, never guessed at.
assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None
def test_resolve_is_id_order_independent():
"""The result must not depend on the sync order of the terms."""
assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351