diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index 6a1fcd4..531c382 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -200,6 +200,30 @@ def test_get_not_found(client, db): assert resp.status_code == 404 + +def test_update_omitting_species_id_preserves_it(client, db): + """Review PUTs omit species_id after Measure owns it — must not null the column.""" + slab_id = str(uuid.uuid4()) + create = { + "id": slab_id, "sku": "", "length_in": 48, "thickness_in": 2, "sqft": 6.25, "bdft": 12.5, + "width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1, + } + r = client.post("/api/v1/slabs", json=create) + assert r.status_code == 201, r.text + assert r.json()["species_id"] == 1 + rev = r.json()["server_rev"] + # PUT like Review commitReview: wood cats only, no species_id key. + r2 = client.put(f"/api/v1/slabs/{slab_id}", json={ + "client_rev": rev, + "wood_category_ids": [2], + "title": None, + "short_description": None, + }) + assert r2.status_code == 200, r2.text + assert r2.json()["species_id"] == 1 + assert r2.json()["wood_category_ids"] == [2] + + def test_update_success(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} diff --git a/frontend/src/capture/MeasurePanel.svelte b/frontend/src/capture/MeasurePanel.svelte index 2befc41..b293a44 100644 --- a/frontend/src/capture/MeasurePanel.svelte +++ b/frontend/src/capture/MeasurePanel.svelte @@ -98,12 +98,16 @@ } } - // Keep local species select in sync when draft restore / session updates it. - $: { - const next = state.speciesId == null ? '' : String(state.speciesId); - if (speciesSel !== next) speciesSel = next; + // Sync local select only when session speciesId changes (draft restore / setSpecies). + // Do NOT depend on speciesSel — that fights bind:value and can clear a choice + // before setSpecies lands (or wipe restored ids while options are still loading). + let syncedSpeciesId: number | null | undefined = undefined; + $: if (syncedSpeciesId !== state.speciesId) { + syncedSpeciesId = state.speciesId; + speciesSel = state.speciesId == null ? '' : String(state.speciesId); } + let speciesOptsReady = false; onMount(() => { void getTaxonomy() .then((tax) => { @@ -111,10 +115,12 @@ id: c.id, name: c.name, })); + speciesOptsReady = true; }) .catch(() => { speciesLoadErr = 'Could not load species list.'; speciesOpts = []; + speciesOptsReady = true; }); }); @@ -170,7 +176,10 @@ setSpecies(speciesSel === '' ? null : Number(speciesSel)); }; - $: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null; + $: canCalc = + positiveInches(String(lengthIn)) !== null + && positiveInches(String(thicknessIn)) !== null + && speciesSel !== ''; const doCalc = async () => { calcBusy = true; @@ -294,19 +303,25 @@
- + {#if !speciesOptsReady} + + {:else} + + {/if} {#if speciesLoadErr}

{speciesLoadErr}

{/if} @@ -330,6 +345,7 @@
Sqft{state.results.sqft.toFixed(2)}
Bdft{state.results.bdft.toFixed(2)}
Scale{state.results.scale.toFixed(2)} px/in
+
Species{speciesOpts.find((o) => o.id === state.speciesId)?.name ?? (state.speciesId != null ? `#${state.speciesId}` : '—')}
{#if state.results.sourceBelowFloor}
Source is below the 800px floor for the inventory PNG. Retake a larger photo.
diff --git a/frontend/src/capture/measureInputs.test.ts b/frontend/src/capture/measureInputs.test.ts index 4d2124a..df2e03f 100644 --- a/frontend/src/capture/measureInputs.test.ts +++ b/frontend/src/capture/measureInputs.test.ts @@ -53,3 +53,9 @@ test('MeasurePanel keeps interactive tape controls (merged Axis)', () => { assert.ok(!/Approve tape line/.test(panelSrc), 'Approve tape folded into Calculate'); assert.match(panelSrc, /Calculate/); }); + +test('Calculate requires Species (carries to createSlab / Create title)', () => { + assert.match(panelSrc, /speciesSel !== ''/); + assert.match(panelSrc, /speciesOptsReady/); + assert.match(panelSrc, /photos-ready-species/); +}); diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 6fb1ea6..2f2d07a 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -6,6 +6,7 @@ type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow, } from '../../api/slabs'; import type { CalibratedCapture } from '../flow'; + import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture'; import MultiSelect from './MultiSelect.svelte'; import { deriveThicknessTermId } from './thicknessBand'; import { @@ -92,7 +93,7 @@ // --- Editable model (mirrors the live slab) --- // Species is set on Measure / createSlab — Review only reads it (no picker). - // Kept as a string so canCreateTitle can gate on a non-empty value from hydrate. + // speciesSel mirrors the resolved id for hydrate/debug; gates use effectiveSpeciesId. let speciesSel: string = ''; // #90: single-selects bind a STRING var to the native