merge issue-89-metadata-lock → main (Create-string model, #89/#90)

Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
This commit is contained in:
Ty
2026-09-21 11:54:48 -07:00
parent e5acef4c9b
commit 1a50fad0a5
11 changed files with 234 additions and 314 deletions
+30 -47
View File
@@ -126,7 +126,6 @@ function baseSlab(): Record<string, unknown> {
short_description: null,
description: null,
content_source: 'manual',
metadata_locked: false,
species_confidence: null,
figure_confidence: null,
grade_confidence: null,
@@ -169,56 +168,41 @@ async function mockApi(page: Page, hooks: ApiHooks = {}) {
'sku', 'species_id', 'wood_category_ids', 'edge_type_term_id', 'figure_term_ids',
'grade_term_id', 'thickness_term_id', 'moisture_term_id', 'fig_tag_ids', 'feat_tag_ids',
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
'metadata_locked',
]) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
// #89: deterministic copy is built only on an explicit lock. Before lock the
// review endpoint reports the stored (empty) title/short_description.
const buildCopy = () => {
const speciesName = TAXONOMY.categories.find((c) => c.id === slab.species_id)?.name ?? 'Slab';
const fig = Array.isArray(slab.figure_term_ids) && slab.figure_term_ids.length
? 'Figured'
: '';
return `${fig} ${speciesName} Live Edge Slab`;
};
const reviewResult = (payload: Record<string, unknown>) => {
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// Explicit lock (#89): persist the deterministic copy + lock flag.
if (payload.action === 'lock') {
const title = (typeof payload.title === 'string' && payload.title)
? (payload.title as string)
: buildCopy();
(slab as Record<string, unknown>).title = title;
(slab as Record<string, unknown>).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`;
(slab as Record<string, unknown>).metadata_locked = true;
}
// #89: the review ALWAYS returns the deterministic title/short_description built
// from the current metadata (the Create button's source of truth). The server
// does NOT persist title/short_description from the review payload.
const hasSpecies = !!slab.species_id;
const hasWoodCat = Array.isArray(slab.wood_category_ids) && slab.wood_category_ids.length > 0;
const deterministicTitle =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab' : null;
const deterministicShort =
hasSpecies && hasWoodCat ? 'Cedar Live Edge Slab, 4ft 0in x 11in x 2in, 12.5 bdft' : null;
const locked = (slab.metadata_locked as boolean) === true;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
return {
ready: missing.length === 0,
missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
title: deterministicTitle,
short_description: deterministicShort,
price: (slab.price as number) ?? null,
price_suggestion: 120,
primary_wood_category_id: primary,
locked,
};
};
@@ -332,12 +316,11 @@ async function completeForReady(page: Page) {
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
// #89: lock the confirmed metadata so the server builds + persists the
// deterministic title/short_description. Do this BEFORE the SKU blur so a
// subsequent plain review can't clobber the locked-note banner.
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
await page.getByTestId('lock-metadata').click();
// Wait for the lock to complete (title field populated = lock persisted copy).
// #89: fire the deterministic build so the title/short_description populate.
// Do this BEFORE the SKU blur so a subsequent plain review doesn't race.
await expect(page.getByTestId('create-title')).toBeEnabled();
await page.getByTestId('create-title').click();
// Wait for the build to complete (title field populated = deterministic copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single
@@ -347,15 +330,14 @@ async function completeForReady(page: Page) {
}
test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => {
test('golden path: capture→review→create→ready→publish→done (#77, #89)', async ({ page }) => {
const { slab } = await mockApi(page);
await reachReview(page);
// Not ready until fields are set; copy is empty until an explicit lock.
// Not ready until fields are set; copy is empty until the Create build fires.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page);
// #89: the lock persisted the deterministic copy on the slab row.
expect(slab.metadata_locked).toBe(true);
// #89: the Create build populated the deterministic copy on the slab row.
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off.
@@ -396,17 +378,17 @@ test.describe('M3 Review→Publish flow (#77)', () => {
await expect.poll(() => reviewPrimaries.at(-1)).toBe(BOWL_ID);
});
test('thickness band is read-only and shows the derived band (#90, METADATA-15)', async ({ page }) => {
test('thickness band is server-derived and NOT shown on Review (#90, METADATA-15)', async ({ page }) => {
await mockApi(page);
await reachReview(page);
// The band is derived server-side, so Review displays it as data.
const band = page.getByTestId('thickness-band');
await expect(band).toBeVisible();
await expect(band).toContainText('2" - 3"'); // 2 in rounds into the live band
// There is no owner control for it anywhere on Review — not a select, not an input.
// METADATA-15 / QUALITY-95: the band is derived server-side from the 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 is the measured value in the summary. (Commit 204bc5e dropped the
// read-only echo; this test asserts the omission.)
await expect(page.getByTestId('thickness-band')).toHaveCount(0);
await expect(page.locator('#thickness, select[data-testid="thickness-band"]')).toHaveCount(0);
expect(await band.evaluate((el) => el.tagName)).toBe('P');
});
/**
@@ -476,7 +458,7 @@ test.describe('M3 Review→Publish flow (#77)', () => {
band: box('[data-testid="thickness-band"]'),
primarySet: box('[data-testid="primary-wood-set"]'),
primaryRowHeight: primaryRowBox ? Math.round(primaryRowBox.height) : null,
lockBtn: box('[data-testid="lock-metadata"]'),
createBtn: box('[data-testid="create-title"]'),
continueBtn: box('[data-testid="continue-publish"]'),
};
});
@@ -495,7 +477,8 @@ test.describe('M3 Review→Publish flow (#77)', () => {
expect(metrics.summary?.text).toMatch(/Bowl blanks/i);
expect(metrics.count?.text).toContain('2 selected');
expect(metrics.summary?.w ?? 0).toBeLessThanOrEqual(vp.w);
expect(metrics.band?.text).toContain('2" - 3"');
// Band is deliberately absent from Review (METADATA-15) — no element to measure.
expect(metrics.band).toBeNull();
expect(metrics.primaryRowHeight ?? 0).toBeGreaterThanOrEqual(32);
}
console.log('RENDER REPORT\n' + report.join('\n'));