From 204bc5e9605e622a3347671de9ad0b8e1cadbbe1 Mon Sep 17 00:00:00 2001 From: Ty Date: Mon, 21 Sep 2026 02:49:47 -0700 Subject: [PATCH] fix(review): #90 thickness band omitted entirely (not read-only) --- backend/tests/test_publish.py | 71 ++++++++++++++++++++++++ docs/UAT-PRACTICE.md | 7 ++- docs/contracts/UX.md | 6 +- frontend/src/create/review/Review.svelte | 21 ++----- 4 files changed, 83 insertions(+), 22 deletions(-) diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py index f9e1638..8ce6e25 100644 --- a/backend/tests/test_publish.py +++ b/backend/tests/test_publish.py @@ -45,6 +45,21 @@ def env(monkeypatch): conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)") conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)") conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)") + # Thickness bands, names as synced (the derivation parses them) — QUALITY-95. + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)") + 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 INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)", + (tid, name, slug, tid), + ) + # Moisture keeps its own attribute so the five locked attributes stay distinct. + conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)") + conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)") # creds conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)") conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),)) @@ -119,6 +134,62 @@ def test_publish_success(env, monkeypatch): assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None +def test_publish_sends_derived_thickness_band(env, monkeypatch): + """#90 / QUALITY-95: the band is off the Review screen but must still reach Woo. + + The owner has no thickness-band control, so nothing client-side carries it: the + server derives it from the measurement on create/update, and publish must send the + live synced term name on the product payload. This pins that chain end to end. + """ + client, conn = env + create = client.post("/api/v1/slabs", json={ + "id": "s1", "sku": "SLAB-BAND", "length_in": 114.0, "thickness_in": 1.75, + "sqft": 30.0, "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3, + }) + assert create.status_code == 201, create.text + # 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client. + assert create.json()["thickness_term_id"] == 351 + + put = client.put("/api/v1/slabs/s1", json={ + "client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2], + "edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326, + "price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S", + "description": "D", "content_source": "manual", + }) + assert put.status_code == 200, put.text + conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'") + conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')") + conn.commit() + + products = [] + + def fake_request(url, method, data, headers): + if "/products?sku=" in url: + return 200, {}, b"[]" + if url.endswith("/wp-json/wp/v2/media") and method == "POST": + return 201, {}, json.dumps({"id": 42}).encode() + if url.endswith("/wp-json/wc/v3/products") and method == "POST": + products.append(json.loads(data)) + return 201, {}, json.dumps({"id": 999}).encode() + return 200, {}, b"[]" + + from backend.woo import WooClient + + orig_init = WooClient.__init__ + + def patched_init(self, *a, **k): + orig_init(self, *a, **k) + self._request = fake_request + + monkeypatch.setattr(WooClient, "__init__", patched_init) + r = client.post("/api/v1/slabs/s1/publish") + assert r.status_code == 200, r.text + assert len(products) == 1, "expected exactly one product create" + attrs = {a["id"]: a for a in products[0].get("attributes", [])} + assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}" + assert attrs[9]["options"] == ['1 1/2" - 2"'] + + def test_publish_woo_image_order_by_role(env, monkeypatch): # PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by # role, not by stored seq (the Review strip stores the owner's keep order). diff --git a/docs/UAT-PRACTICE.md b/docs/UAT-PRACTICE.md index 078804c..0f2f442 100644 --- a/docs/UAT-PRACTICE.md +++ b/docs/UAT-PRACTICE.md @@ -121,9 +121,10 @@ hard stop. Switch to a supported browser. 3. **Wood categories.** Pick at least one from the synced list. 4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure, exactly one grade. -5. **Thickness band.** Derived, not picked: the server bands the measured thickness up into - the synced Woo band and Review shows it read-only. Verify the shown band is the one the - captured thickness rounds up into (`QUALITY-95`). +5. **Thickness band.** Not on the screen and not picked: the server bands the measured thickness + up into the synced Woo band, stores it, and publish sends it to the store. Verify after publish + that the Woo product's Thickness attribute is the band the captured thickness rounds up into + (`QUALITY-95`). 6. **Moisture.** Keep the default (kiln-dried) or change it. 7. **fig-* tags.** Optional; pick any from the synced list. 8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags. diff --git a/docs/contracts/UX.md b/docs/contracts/UX.md index f297846..a5f2e90 100644 --- a/docs/contracts/UX.md +++ b/docs/contracts/UX.md @@ -59,9 +59,9 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45. optional; price; title, short_description, and description. - **Primary category.** The owner declares one primary wood category and any secondary ones; the default is the first selected. -- **Thickness band is derived, not picked.** The measured thickness bands up into the synced Woo - band server-side (`METADATA-15`, `QUALITY-95`); Review shows the assigned band read-only and - offers no owner control for it. +- **Thickness band is derived and not shown.** The measured thickness bands up into the synced Woo + band server-side (`METADATA-15`, `QUALITY-95`); Review carries no thickness-band control and no + read-only echo of it. The derived band is stored on the slab and sent to the store at publish. - **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is no single submit-time gap list. - **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable. diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index ff309e0..a6274cc 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -39,7 +39,6 @@ let edgeOpts: Opt[] = []; let figureOpts: Opt[] = []; let gradeOpts: Opt[] = []; - let thicknessOpts: Opt[] = []; let moistureOpts: Opt[] = []; let figTagOpts: Opt[] = []; let featTagOpts: Opt[] = []; @@ -76,7 +75,8 @@ edgeOpts = termsOf('edge type'); figureOpts = termsOf('figure'); gradeOpts = termsOf('grade'); - thicknessOpts = termsOf('thickness'); + // No thickness options: the band is derived server-side and never shown here + // (METADATA-15 / QUALITY-95). moistureOpts = termsOf('moisture'); // fig-* / feat-* product tags (METADATA-30): pulled from the live synced set, // filtered by the family the backend keeps. Never invent a prefix — if the @@ -346,11 +346,9 @@ .map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); // METADATA-15 / QUALITY-95: the thickness band is derived server-side from the - // measured thickness against the live synced Woo bands. There is no owner - // control for it — this is the read-only display of the band the server assigned. - $: thicknessBandName = - thicknessOpts.find((o) => o.id === (slab?.thickness_term_id ?? -1))?.name ?? ''; - + // measured thickness against the live synced Woo bands. It is deliberately absent + // from this screen — no owner control, no read-only echo. The only thickness the + // owner sees here is the measured value in the summary above. const aiIn = (ids: number[]) => (id: number) => ids.includes(id); // Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready. async function commitReview(): Promise { @@ -730,12 +728,6 @@ /> -
- -

- {#if thicknessBandName}{thicknessBandName}{:else}— not yet resolved{/if} -

-