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:
@@ -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'));
|
||||
|
||||
@@ -43,7 +43,7 @@ function baseSlab(): Record<string, unknown> {
|
||||
species_id: null, wood_category_ids: [], edge_type_term_id: null, figure_term_ids: [], grade_term_id: null,
|
||||
thickness_term_id: null, moisture_term_id: null, fig_tag_ids: [], feat_tag_ids: [],
|
||||
title: null, short_description: null, description: null, content_source: 'manual',
|
||||
metadata_locked: false, species_confidence: null, figure_confidence: null, grade_confidence: null,
|
||||
species_confidence: null, figure_confidence: null, grade_confidence: null,
|
||||
inference_status: null, calibration_photo_id: null, woo_product_id: null, server_rev: 0,
|
||||
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
@@ -66,7 +66,7 @@ async function mockApi(page: Page) {
|
||||
|
||||
const merge = (payload: Record<string, unknown>) => {
|
||||
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) return false;
|
||||
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'metadata_locked', 'edge_type_term_id']) {
|
||||
for (const k of ['sku', 'species_id', 'wood_category_ids', 'title', 'short_description', 'price', 'edge_type_term_id']) {
|
||||
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;
|
||||
@@ -91,7 +91,7 @@ async function mockApi(page: Page) {
|
||||
title: (slab.title as string) ?? null,
|
||||
short_description: (slab.short_description as string) ?? null,
|
||||
price: (slab.price as number) ?? null, price_suggestion: 120,
|
||||
primary_wood_category_id: null, locked: slab.metadata_locked === true,
|
||||
primary_wood_category_id: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -181,8 +181,8 @@ 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');
|
||||
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
|
||||
await page.getByTestId('lock-metadata').click();
|
||||
await expect(page.getByTestId('create-title')).toBeEnabled();
|
||||
await page.getByTestId('create-title').click();
|
||||
await expect(page.getByTestId('title')).not.toHaveValue('');
|
||||
await page.getByTestId('sku').fill('SLAB-0001');
|
||||
await page.getByTestId('sku').press('Tab');
|
||||
|
||||
Reference in New Issue
Block a user