feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { apiFetch, ApiError } from '../lib/api';
|
||||
import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types';
|
||||
import { deriveThicknessBandId } from './thicknessBand';
|
||||
import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
import { buildShortDescription, buildTitle } from './copy';
|
||||
|
||||
export let slab: SlabDraft;
|
||||
export let taxonomy: Taxonomy | null = null;
|
||||
|
||||
// Children of a synced Woo grouper, resolved by slug with a name fallback
|
||||
// (QUALITY-105 / METADATA-5 — never hardcoded ids).
|
||||
function leafChildren(categories: TaxonomyCategory[], slug: string, name: string): TaxonomyCategory[] {
|
||||
if (!categories.length) return [];
|
||||
const grouper =
|
||||
categories.find((c) => (c.slug || '').toLowerCase() === slug) ??
|
||||
categories.find((c) => (c.name || '').trim().toLowerCase() === name);
|
||||
if (!grouper) return [];
|
||||
return categories
|
||||
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
ready: Partial<SlabDraft>;
|
||||
back: void;
|
||||
taxonomy: Taxonomy;
|
||||
saved: SlabDraft;
|
||||
}>();
|
||||
|
||||
// ── Taxonomy lookups (from the synced /api/v1 response — never hardcoded) ──
|
||||
$: categories = taxonomy?.categories ?? [];
|
||||
$: speciesLeaves = leafChildren(categories, 'species', 'Species');
|
||||
$: woodCatLeaves = leafChildren(categories, 'wcat', 'Wood category');
|
||||
$: attributeByName = (name: string) =>
|
||||
(taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === name || a.slug.toLowerCase() === name);
|
||||
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
|
||||
$: figureTerms = attributeByName('Figure')?.terms ?? [];
|
||||
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
|
||||
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
|
||||
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
|
||||
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('fig-'));
|
||||
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('feat-'));
|
||||
$: speciesName = (id: number | null) => categories.find((c) => c.id === id)?.name ?? '';
|
||||
$: figureName = figureTerms.find((t) => t.id === (slab.figureTermIds?.[0] ?? null))?.name ?? null;
|
||||
|
||||
// ── Draft fields ──
|
||||
let sku = slab.sku || '';
|
||||
let speciesId: number | '' = slab.speciesId ?? '';
|
||||
let woodCatIds: number[] = slab.woodCategoryIds.length ? [...slab.woodCategoryIds] : [];
|
||||
let primaryCatId: number | '' = slab.woodCategoryIds[0] ?? '';
|
||||
let edgeId: number | '' = slab.edgeTypeTermId ?? '';
|
||||
let figIds: number[] = slab.figureTermIds.length ? [...slab.figureTermIds] : [];
|
||||
let gradeId: number | '' = slab.gradeTermId ?? '';
|
||||
let moistId: number | '' = slab.moistureTermId ?? '';
|
||||
let figTagIds: number[] = slab.figTagIds ?? [];
|
||||
let featTagIds: number[] = slab.featTagIds ?? [];
|
||||
let price = slab.price || 0;
|
||||
let priceSource: 'recommendation' | 'override' = slab.priceSource;
|
||||
let title = slab.title || '';
|
||||
let shortDesc = slab.shortDescription || '';
|
||||
let description = slab.description || '';
|
||||
|
||||
// Derived thickness band (METADATA-15 / QUALITY-95): no picker, follows thickness_in.
|
||||
$: bandId = deriveThicknessBandId(slab.thicknessIn, thicknessTerms);
|
||||
$: bandName = thicknessTerms.find((t) => t.id === bandId)?.name ?? '';
|
||||
|
||||
$: edgeName = edgeTerms.find((t) => t.id === edgeId)?.name ?? null;
|
||||
$: primaryLeaf = categories.find((c) => c.id === primaryCatId);
|
||||
|
||||
function regenerateCopy() {
|
||||
if (!primaryLeaf || !speciesName(speciesId as number)) return;
|
||||
title = buildTitle(
|
||||
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
|
||||
figureName,
|
||||
speciesName(speciesId as number),
|
||||
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
|
||||
);
|
||||
shortDesc = buildShortDescription(
|
||||
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
|
||||
figureName,
|
||||
speciesName(speciesId as number),
|
||||
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Price: bdft × species rate (REVIEW-25); owner override sticks ──
|
||||
$: priceRec = priceSuggestion();
|
||||
function priceSuggestion(): number | null {
|
||||
const rule = (slab as SlabDraft & { speciesRate?: number | null }).speciesRate;
|
||||
if (!slab.bdft || rule == null) return null;
|
||||
return Math.round(slab.bdft * rule * 100) / 100;
|
||||
}
|
||||
function useRecommendedPrice() {
|
||||
if (priceRec == null) return;
|
||||
price = priceRec;
|
||||
priceSource = 'recommendation';
|
||||
}
|
||||
function onPriceInput() {
|
||||
price = Math.round(price * 100) / 100;
|
||||
priceSource = 'override';
|
||||
}
|
||||
|
||||
// ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ──
|
||||
$: lineup = slab.photos.map((photo, index): LineupItem => ({
|
||||
id: photo.id,
|
||||
role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch',
|
||||
isCalibration: photo.id === slab.calibrationPhotoId,
|
||||
kept: index < slab.photos.length,
|
||||
}));
|
||||
$: lineupMissing = missingMinimums(lineup);
|
||||
$: primaryPhoto = keptInOrder(lineup)[0];
|
||||
|
||||
function toggleKeep(id: string) {
|
||||
const next = lineup.map((it) => (it.id === id ? { ...it, kept: !it.kept } : it));
|
||||
resequence(next);
|
||||
}
|
||||
function movePhoto(from: number, to: number) {
|
||||
resequence(moveItem(lineup, from, to));
|
||||
}
|
||||
function resequence(items: LineupItem[]) {
|
||||
const kept = keptInOrder(items);
|
||||
const update: Partial<SlabDraft> = {
|
||||
photos: kept.map((it, i) => {
|
||||
const photo = slab.photos.find((p) => p.id === it.id);
|
||||
return photo ? { ...photo, seq: i + 1 } : photo;
|
||||
}),
|
||||
};
|
||||
dispatch('saved', { ...slab, ...update, clientRev: slab.clientRev + 1 });
|
||||
}
|
||||
function photoSrc(photoId: string, role: PhotoRole): string {
|
||||
const photo = slab.photos.find((p) => p.id === photoId);
|
||||
if (!photo) return '';
|
||||
if (role === 'batch') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
|
||||
if (role === 'overlay') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
|
||||
return photo.url;
|
||||
}
|
||||
|
||||
// ── Multi-select helpers ──
|
||||
$: woodCatNames = new Map(woodCatLeaves.map((c) => [c.id, c.name]));
|
||||
$: figNames = new Map(figureTerms.map((t) => [t.id, t.name]));
|
||||
|
||||
function toggleWoodCat(id: number) {
|
||||
const selected = woodCatIds.includes(id) ? woodCatIds.filter((x) => x !== id) : [...woodCatIds, id];
|
||||
woodCatIds = selected;
|
||||
primaryCatId = nextPrimary(selected, primaryCatId);
|
||||
regenerateCopy();
|
||||
}
|
||||
function toggleFig(id: number) {
|
||||
figIds = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
|
||||
regenerateCopy();
|
||||
}
|
||||
function toggleTag(list: number[], id: number): number[] {
|
||||
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
|
||||
// ── Call 2: long description (METADATA-115) with template fallback ──
|
||||
let genState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle';
|
||||
let genMessage = '';
|
||||
function templateDescription(): string {
|
||||
return (
|
||||
`A ${title || 'slab'} from the mill, measured at ${slab.lengthIn}in long, ` +
|
||||
`${slab.widthAvgIn}in average width, and ${slab.thicknessIn}in thick (${slab.bdft} bdft). ` +
|
||||
'Ready to ship.'
|
||||
);
|
||||
}
|
||||
function useTemplateDescription() {
|
||||
description = templateDescription();
|
||||
genState = 'idle';
|
||||
genMessage = 'Template description applied.';
|
||||
}
|
||||
async function generateDescription() {
|
||||
genState = 'loading';
|
||||
genMessage = '';
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; description: string }>(`/slabs/${slab.id}/inference/call2`, { method: 'POST' });
|
||||
description = res.description;
|
||||
genState = 'ok';
|
||||
genMessage = 'Long description generated (Call 2).';
|
||||
} catch (err) {
|
||||
genState = 'failed';
|
||||
genMessage = err instanceof ApiError ? `${err.message} — use the template fallback.` : 'Generation failed — use the template fallback.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ready gate (REVIEW-5) ──
|
||||
$: skuOk = /^[A-Za-z0-9-]{3,24}$/.test(sku.trim());
|
||||
$: ready =
|
||||
skuOk && speciesId !== '' && woodCatIds.length > 0 && edgeId !== '' && figIds.length > 0 &&
|
||||
gradeId !== '' && bandId !== null && moistId !== '' && price > 0 && title.trim().length > 0 &&
|
||||
shortDesc.trim().length > 0 && description.trim().length > 0;
|
||||
$: missing = [];
|
||||
$: if (!skuOk) missing.push('SKU (3–24 alnum/-)');
|
||||
$: if (speciesId === '') missing.push('Species');
|
||||
$: if (!woodCatIds.length) missing.push('Wood category');
|
||||
$: if (edgeId === '') missing.push('Edge type');
|
||||
$: if (!figIds.length) missing.push('Figure');
|
||||
$: if (gradeId === '') missing.push('Grade');
|
||||
$: if (bandId === null) missing.push('Thickness band (set thickness)');
|
||||
$: if (moistId === '') missing.push('Moisture');
|
||||
$: if (!(price > 0)) missing.push('Price');
|
||||
$: if (!description.trim()) missing.push('Description');
|
||||
|
||||
async function submitReady() {
|
||||
if (!ready) return;
|
||||
const update: Partial<SlabDraft> = {
|
||||
sku: sku.trim(),
|
||||
speciesId: speciesId as number,
|
||||
woodCategoryIds: woodCatIds,
|
||||
edgeTypeTermId: edgeId as number,
|
||||
figureTermIds: figIds,
|
||||
gradeTermId: gradeId as number,
|
||||
thicknessTermId: bandId,
|
||||
moistureTermId: moistId as number,
|
||||
figTagIds,
|
||||
featTagIds,
|
||||
price,
|
||||
priceSource,
|
||||
title,
|
||||
shortDescription: shortDesc,
|
||||
description,
|
||||
status: 'ready',
|
||||
};
|
||||
dispatch('ready', update);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>3. Review (METADATA/REVIEW)</h2>
|
||||
{#if !taxonomy}
|
||||
<p>Loading synced taxonomy…</p>
|
||||
{:else if !categories.length}
|
||||
<p class="nudge">Taxonomy is empty — run a Woo sync in Settings first.</p>
|
||||
{:else}
|
||||
<div class="form">
|
||||
<label>SKU <input bind:value={sku} maxlength="24" placeholder="SLAB-UAT-001" /></label>
|
||||
|
||||
<label>Species
|
||||
<select bind:value={speciesId} on:change={regenerateCopy}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each speciesLeaves as s (s.id)}<option value={s.id}>{s.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="chip-group">
|
||||
<span>Wood categories <small>— primary drives the title</small></span>
|
||||
{#each woodCatLeaves as c (c.id)}
|
||||
<button class="chip" class:selected={woodCatIds.includes(c.id)} on:click={() => toggleWoodCat(c.id)}>
|
||||
{#if primaryCatId === c.id}<strong>★</strong>{/if}{c.name}
|
||||
</button>
|
||||
{/each}
|
||||
<small class="chip-sum">{selectedSummary(woodCatIds, woodCatNames, 'None selected')}</small>
|
||||
</div>
|
||||
|
||||
<label>Edge type
|
||||
<select bind:value={edgeId} on:change={regenerateCopy}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each edgeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="chip-group">
|
||||
<span>Figure (multi)</span>
|
||||
{#each figureTerms as t (t.id)}
|
||||
<button class="chip" class:selected={figIds.includes(t.id)} on:click={() => toggleFig(t.id)}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label>Grade
|
||||
<select bind:value={gradeId}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each gradeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="band">
|
||||
<span>Thickness band (derived from {slab.thicknessIn}″)</span>
|
||||
<strong>{bandName || '— set thickness in Measure —'}</strong>
|
||||
</div>
|
||||
|
||||
<label>Moisture
|
||||
<select bind:value={moistId}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each moistureTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if figTags.length}
|
||||
<div class="chip-group">
|
||||
<span>Figure tags</span>
|
||||
{#each figTags as t (t.id)}
|
||||
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => (figTagIds = toggleTag(figTagIds, t.id))}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if featTags.length}
|
||||
<div class="chip-group">
|
||||
<span>Feature tags</span>
|
||||
{#each featTags as t (t.id)}
|
||||
<button class="chip" class:selected={featTagIds.includes(t.id)} on:click={() => (featTagIds = toggleTag(featTagIds, t.id))}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="price">
|
||||
<label>Price ($) <input type="number" step="0.01" bind:value={price} on:input={onPriceInput} /></label>
|
||||
{#if priceRec != null}
|
||||
<button on:click={useRecommendedPrice}>Use recommendation ${priceRec} (bdft × species rate)</button>
|
||||
{:else}
|
||||
<small class="nudge">No species rate configured — set it in Settings.</small>
|
||||
{/if}
|
||||
<small>Source: {priceSource}</small>
|
||||
</div>
|
||||
|
||||
<div class="prose">
|
||||
<label>Title (deterministic, editable) <input bind:value={title} /></label>
|
||||
<label>Short description (deterministic, editable) <input bind:value={shortDesc} /></label>
|
||||
<label>Long description
|
||||
<textarea bind:value={description} rows="5"></textarea>
|
||||
</label>
|
||||
<div class="gen">
|
||||
<button on:click={generateDescription} disabled={genState === 'loading'}>
|
||||
{genState === 'loading' ? 'Generating…' : 'Generate (Call 2)'}
|
||||
</button>
|
||||
<button on:click={useTemplateDescription}>Use template</button>
|
||||
{#if genMessage}<p class:ok={genState === 'ok'} class:err={genState === 'failed'}>{genMessage}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="strip">
|
||||
<span>Photo lineup — leftmost kept = primary (cover). Tap to keep/remove; use ‹ › to reorder.</span>
|
||||
{#each lineup as item, i (item.id)}
|
||||
{#if item.kept}
|
||||
<div class="strip-item" class:primary={item.id === primaryPhoto?.id}>
|
||||
<button class="keep" aria-label="keep or remove" on:click={() => toggleKeep(item.id)}>{item.id === primaryPhoto?.id ? '★' : '✓'}</button>
|
||||
<img src={photoSrc(item.id, item.role)} alt={roleLabel(item.role)} />
|
||||
<div class="strip-actions">
|
||||
<button disabled={i === 0} on:click={() => movePhoto(i, i - 1)}>‹</button>
|
||||
<button disabled={i === lineup.length - 1} on:click={() => movePhoto(i, i + 1)}>›</button>
|
||||
</div>
|
||||
<small>{roleLabel(item.role)}</small>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if lineupMissing.length}
|
||||
<p class="nudge">Minimum not met — missing {lineupMissing.map(roleLabel).join(' and ')}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ready}
|
||||
<p class="nudge">Still needed: {missing.join(', ')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={() => dispatch('back')}>← Back to measure</button>
|
||||
<button on:click={submitReady} disabled={!ready} class="primary">Mark Ready & Publish →</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.form { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:0.9rem; }
|
||||
label { display:block; font-size:0.9rem; }
|
||||
input,select,textarea { width:100%; padding:0.4rem; background:#222; color:#eee; border:1px solid #555; margin-top:0.2rem; }
|
||||
.chip-group { display:flex; flex-wrap:wrap; gap:0.35rem; align-items:center; }
|
||||
.chip { background:#222; color:#eee; border:1px solid #555; padding:0.35rem 0.7rem; border-radius:999px; cursor:pointer; }
|
||||
.chip.selected { background:#0a7; color:#fff; border-color:#0a7; }
|
||||
.chip-sum { width:100%; color:#888; }
|
||||
.band { background:#1a1a1a; padding:0.6rem 0.8rem; border-radius:4px; }
|
||||
.band strong { display:block; color:#0af; }
|
||||
.price button { margin-top:0.3rem; font-size:0.8rem; }
|
||||
.gen { display:flex; gap:0.5rem; align-items:center; margin-top:0.3rem; }
|
||||
.gen .ok { color:#0a7; margin:0; }
|
||||
.gen .err { color:#f88; margin:0; }
|
||||
.strip { grid-column:1/-1; }
|
||||
.strip-item { position:relative; display:inline-block; margin:0.3rem; }
|
||||
.strip-item img { width:140px; height:140px; object-fit:cover; background:#111; border:2px solid #444; }
|
||||
.strip-item.primary img { border-color:#0a7; }
|
||||
.keep { position:absolute; top:4px; left:4px; z-index:1; background:#111; color:#0a7; border:1px solid #0a7; border-radius:4px; }
|
||||
.strip-actions { display:flex; gap:0.25rem; }
|
||||
.nudge { color:#fa0; }
|
||||
.primary { background:#0a7; color:white; border:none; }
|
||||
.actions { display:flex; gap:0.75rem; margin-top:1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { buildShortDescription, buildTitle, type CopyFacts } from './copy';
|
||||
|
||||
const FACTS: CopyFacts = { lengthIn: 48, widthAvgIn: 18, thicknessIn: 2, bdft: 12 };
|
||||
|
||||
test('copy: live-edge leaf title, no dims, figure drops when absent', () => {
|
||||
assert.equal(
|
||||
buildTitle(FACTS, 'Cathedral', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Cathedral Black Walnut Live Edge Slab',
|
||||
);
|
||||
assert.equal(
|
||||
buildTitle(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Black Walnut Live Edge Slab',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: dimensional leaf gains "Live Edge" only from the synced edge term (METADATA-5)', () => {
|
||||
assert.equal(
|
||||
buildTitle({ ...FACTS, edgeName: 'Live' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }),
|
||||
'Red Oak Live Edge Dimensional',
|
||||
);
|
||||
assert.equal(
|
||||
buildTitle({ ...FACTS, edgeName: 'Straight' }, null, 'Red Oak', { slug: 'dimensional-wood', name: 'Dimensional wood' }),
|
||||
'Red Oak Dimensional',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: knife-blank leaf carries dims', () => {
|
||||
assert.equal(
|
||||
buildTitle(FACTS, null, 'Cherry', { slug: 'knife-blanks', name: 'Knife blanks' }),
|
||||
'Cherry Knife Blank (48 x 18 x 2 in)',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: unknown leaf slug falls back to "Slab"', () => {
|
||||
assert.equal(
|
||||
buildTitle(FACTS, 'Spalted', 'Olive', { slug: 'mystery-leaf', name: 'Mystery' }),
|
||||
'Spalted Olive Slab',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: title collapses double spaces', () => {
|
||||
assert.equal(
|
||||
buildTitle(FACTS, ' ', 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Black Walnut Live Edge Slab',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: short description = title + ft/in split + dims + rounded bdft', () => {
|
||||
assert.equal(
|
||||
buildShortDescription(FACTS, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 12 bdft',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: short description keeps fractional bdft to 1 dp', () => {
|
||||
assert.equal(
|
||||
buildShortDescription({ ...FACTS, bdft: 11.46 }, null, 'Black Walnut', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Black Walnut Live Edge Slab, 4ft 0in x 18in x 2in, 11.5 bdft',
|
||||
);
|
||||
});
|
||||
|
||||
test('copy: non-whole feet split into ft + rem inches', () => {
|
||||
assert.equal(
|
||||
buildShortDescription({ ...FACTS, lengthIn: 60 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Cherry Live Edge Slab, 5ft 0in x 18in x 2in, 12 bdft',
|
||||
);
|
||||
assert.equal(
|
||||
buildShortDescription({ ...FACTS, lengthIn: 41.5 }, null, 'Cherry', { slug: 'live-edge-wood', name: 'Live edge wood' }),
|
||||
'Cherry Live Edge Slab, 3ft 5.5in x 18in x 2in, 12 bdft',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Deterministic title / short description (METADATA-80/85).
|
||||
*
|
||||
* Mirrors the backend's `build_title` / `build_short_description` so the Review
|
||||
* screen previews exactly what the server will assemble. Patterns interpolate
|
||||
* {figure}, {species}, {length}, {width}, {thickness}, {bdft}; figure is a
|
||||
* Title-Case adjective and an absent figure drops the slot. Leaf slugs are the
|
||||
* synced Woo slugs — ids are never hardcoded (QUALITY-105 / METADATA-5).
|
||||
*/
|
||||
|
||||
export interface CopyFacts {
|
||||
lengthIn: number;
|
||||
widthAvgIn: number;
|
||||
thicknessIn: number;
|
||||
bdft: number;
|
||||
/** Name of the selected edge term; a name containing "live" flags live edge (METADATA-5). */
|
||||
edgeName?: string | null;
|
||||
}
|
||||
|
||||
export interface CopyLeaf {
|
||||
slug: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** (pattern, noun, carriesDims) per primary wood-category leaf slug. */
|
||||
const LEAF_PATTERNS: Record<string, { pattern: string; noun: string; carriesDims: boolean }> = {
|
||||
'live-edge-wood': { pattern: '{figure}{species} Live Edge Slab', noun: 'Live Edge Slab', carriesDims: false },
|
||||
'dimensional-wood': { pattern: '{figure}{species} {live_edge}Dimensional', noun: 'Dimensional', carriesDims: false },
|
||||
'knife-blanks': { pattern: '{figure}{species} Knife Blank ({dims})', noun: 'Knife Blank', carriesDims: true },
|
||||
'bowl-blanks': { pattern: '{figure}{species} {live_edge}Bowl Blank ({dims})', noun: 'Bowl Blank', carriesDims: true },
|
||||
'logs-and-caps': { pattern: '{figure}{species} Log Cap', noun: 'Log Cap', carriesDims: false },
|
||||
'pen-blanks': { pattern: '{figure}{species} Pen Blank ({dims})', noun: 'Pen Blank', carriesDims: true },
|
||||
'pepper-mill-blanks': { pattern: '{figure}{species} Pepper Mill Blank ({dims})', noun: 'Pepper Mill Blank', carriesDims: true },
|
||||
};
|
||||
|
||||
const FALLBACK_PATTERN = { pattern: '{figure}{species} Slab', noun: 'Slab', carriesDims: false };
|
||||
|
||||
function fmt1(v: number): string {
|
||||
const s = v.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
}
|
||||
|
||||
function fmt2(v: number): string {
|
||||
return v.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function dims(facts: CopyFacts): string {
|
||||
return `${fmt1(facts.lengthIn)} x ${fmt1(facts.widthAvgIn)} x ${fmt2(facts.thicknessIn)} in`;
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function collapse(s: string): string {
|
||||
return s.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function splitLength(lengthIn: number): [number, number] {
|
||||
const ft = Math.floor(lengthIn / 12);
|
||||
const rem = Math.round((lengthIn - ft * 12) * 10) / 10;
|
||||
return [ft, rem];
|
||||
}
|
||||
|
||||
function edgeIsLive(edgeName: string | null | undefined): boolean {
|
||||
return (edgeName ?? '').toLowerCase().includes('live');
|
||||
}
|
||||
|
||||
export function buildTitle(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string {
|
||||
const { pattern, carriesDims } = LEAF_PATTERNS[primaryLeaf.slug] ?? FALLBACK_PATTERN;
|
||||
const fig = (figure ?? '').trim();
|
||||
// The live-edge flag is driven by the *synced edge term* for dimensional/bowl
|
||||
// forms — never by any non-null edge id (METADATA-5 / #98).
|
||||
const liveEdge =
|
||||
(primaryLeaf.slug === 'dimensional-wood' || primaryLeaf.slug === 'bowl-blanks') && edgeIsLive(facts.edgeName)
|
||||
? 'Live Edge '
|
||||
: '';
|
||||
const title = pattern
|
||||
.replace('{figure}', fig ? `${titleCase(fig)} ` : '')
|
||||
.replace('{species}', speciesName)
|
||||
.replace('{live_edge}', liveEdge)
|
||||
.replace('{dims}', carriesDims ? dims(facts) : '');
|
||||
return collapse(title);
|
||||
}
|
||||
|
||||
export function buildShortDescription(facts: CopyFacts, figure: string | null, speciesName: string, primaryLeaf: CopyLeaf): string {
|
||||
const title = buildTitle(facts, figure, speciesName, primaryLeaf);
|
||||
const [ft, rem] = splitLength(facts.lengthIn);
|
||||
const line = `${title}, ${ft}ft ${fmt1(rem)}in x ${fmt1(facts.widthAvgIn)}in x ${fmt2(facts.thicknessIn)}in, ${fmt1(Math.round(facts.bdft * 10) / 10)} bdft`;
|
||||
return collapse(line);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
keptInOrder,
|
||||
moveItem,
|
||||
missingMinimums,
|
||||
lineupValid,
|
||||
roleLabel,
|
||||
resolvePhotoUrl,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
|
||||
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
|
||||
id,
|
||||
role,
|
||||
isCalibration: role === 'calibration',
|
||||
kept,
|
||||
});
|
||||
|
||||
test('lineup: keptInOrder returns kept photos in their current order', () => {
|
||||
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
|
||||
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
|
||||
});
|
||||
|
||||
test('lineup: leftmost kept is the primary', () => {
|
||||
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
|
||||
// After a drag the owner re-ordered; primary follows position, not role.
|
||||
assert.equal(keptInOrder(items)[0].id, 'b1');
|
||||
});
|
||||
|
||||
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
|
||||
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
|
||||
assert.equal(keptInOrder(items)[0].id, 'ov');
|
||||
});
|
||||
|
||||
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
|
||||
const a = ['x', 'y', 'z'];
|
||||
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
|
||||
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
|
||||
// Out-of-range is a no-op, never throws.
|
||||
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
|
||||
// Returns a new array, does not mutate the input.
|
||||
const before = a.slice();
|
||||
moveItem(a, 0, 2);
|
||||
assert.deepEqual(a, before);
|
||||
});
|
||||
|
||||
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
|
||||
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
|
||||
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
|
||||
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
|
||||
});
|
||||
|
||||
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
|
||||
assert.equal(lineupValid([p('b1', 'batch')]), false);
|
||||
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
|
||||
});
|
||||
|
||||
test('lineup: missingMinimums only counts KEPT photos', () => {
|
||||
// Both roles present in the array, but the cal is not kept.
|
||||
assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']);
|
||||
});
|
||||
|
||||
test('lineup: roleLabel is owner-facing', () => {
|
||||
assert.match(roleLabel('calibration'), /clean/);
|
||||
assert.match(roleLabel('overlay'), /overlay/);
|
||||
});
|
||||
|
||||
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL', () => {
|
||||
const blob = new Blob(['png-bytes'], { type: 'image/png' });
|
||||
const fake = (b: Blob) => `blob:fake-${b.size}`;
|
||||
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
|
||||
// Empty/absent blob falls back to the stored endpoint.
|
||||
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Review photo-strip lineup (REVIEW-5 / PUBLISH-45). Pure logic so keep/remove,
|
||||
* reorder, and the submission minimum are unit-testable without a browser.
|
||||
*
|
||||
* The owner taps photos to keep/remove and drags to reorder; the LEFTMOST kept
|
||||
* photo is the primary (Woo cover). Submission minimum: at least one clean
|
||||
* calibration photo AND its dimension-overlay copy (REVIEW-5). Batch photos
|
||||
* are optional.
|
||||
*/
|
||||
|
||||
export type PhotoRole = 'calibration' | 'overlay' | 'batch';
|
||||
|
||||
export interface LineupItem {
|
||||
id: string;
|
||||
role: PhotoRole;
|
||||
isCalibration: boolean;
|
||||
/** True = the owner keeps this photo in the submission. */
|
||||
kept: boolean;
|
||||
}
|
||||
|
||||
/** The kept photos in their current (left→right) order; the first is the primary. */
|
||||
export function keptInOrder(items: LineupItem[]): LineupItem[] {
|
||||
return items.filter((it) => it.kept);
|
||||
}
|
||||
|
||||
/** Move an item from one index to another (drag-reorder). Returns a new array. */
|
||||
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) {
|
||||
return arr;
|
||||
}
|
||||
const next = arr.slice();
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the kept lineup meets the submission minimum (REVIEW-5): at least one
|
||||
* clean calibration photo and one dimension-overlay copy. Returns the missing
|
||||
* role(s) so the UI can name them in a nudge.
|
||||
*/
|
||||
export function missingMinimums(items: LineupItem[]): PhotoRole[] {
|
||||
const kept = items.filter((it) => it.kept);
|
||||
const missing: PhotoRole[] = [];
|
||||
if (!kept.some((it) => it.role === 'calibration')) missing.push('calibration');
|
||||
if (!kept.some((it) => it.role === 'overlay')) missing.push('overlay');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** True when the kept lineup is publishable from a photo standpoint. */
|
||||
export function lineupValid(kept: LineupItem[]): boolean {
|
||||
return missingMinimums(kept).length === 0;
|
||||
}
|
||||
|
||||
/** A short owner-facing label for a role (used in the minimum nudge). */
|
||||
export function roleLabel(role: PhotoRole): string {
|
||||
if (role === 'calibration') return 'a clean photo';
|
||||
if (role === 'overlay') return 'its dimension-overlay copy';
|
||||
return 'a batch photo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the <img> src for a strip photo. A fresh in-memory blob must become
|
||||
* a `blob:` object URL — assigning a raw Blob renders a broken image. With no
|
||||
* usable blob, fall back to the stored endpoint.
|
||||
*
|
||||
* Pure + injectable: `createObjectUrl` defaults to `URL.createObjectURL` so it
|
||||
* is unit-testable without a real DOM/Blob.
|
||||
*/
|
||||
export function resolvePhotoUrl(
|
||||
blob: Blob | null,
|
||||
endpoint: string,
|
||||
createObjectUrl: (b: Blob) => string = (b) => URL.createObjectURL(b),
|
||||
): string {
|
||||
if (blob && blob.size > 0) return createObjectUrl(blob);
|
||||
return endpoint;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { deriveThicknessBandId, parseInchToken, termBounds, type ThicknessTerm } from './thicknessBand';
|
||||
|
||||
test('parseInchToken: whole, decimal, fraction, mixed number, quoted', () => {
|
||||
assert.equal(parseInchToken('2'), 2);
|
||||
assert.equal(parseInchToken('1.5'), 1.5);
|
||||
assert.equal(parseInchToken('3/4'), 0.75);
|
||||
assert.equal(parseInchToken('1 1/2'), 1.5);
|
||||
assert.equal(parseInchToken('2"'), 2);
|
||||
assert.equal(parseInchToken(' 1 1/2 '), 1.5);
|
||||
assert.equal(parseInchToken(''), null);
|
||||
assert.equal(parseInchToken('N/A'), null);
|
||||
assert.equal(parseInchToken(null), null);
|
||||
});
|
||||
|
||||
test('termBounds: name first, slug fallback, malformed → null', () => {
|
||||
assert.deepEqual(termBounds({ id: 1, name: '1"–1 1/2"' }), [1, 1.5]);
|
||||
assert.deepEqual(termBounds({ id: 2, name: '1 1/2"–3"', slug: 'x' }), [1.5, 3]);
|
||||
assert.deepEqual(termBounds({ id: 3, name: '3"+' }), [3, Infinity]);
|
||||
assert.deepEqual(termBounds({ id: 4, name: 'under 1"', slug: 'x' }), [0, 1]);
|
||||
assert.deepEqual(termBounds({ id: 5, name: 'over 6"', slug: 'x' }), [6, Infinity]);
|
||||
assert.deepEqual(termBounds({ id: 6, name: 'x', slug: '4-6qtr' }), [1, 1.5]);
|
||||
assert.deepEqual(termBounds({ id: 7, name: 'x', slug: '12qtr' }), [3, Infinity]);
|
||||
assert.deepEqual(termBounds({ id: 8, name: 'x', slug: 'xthin' }), [0, 0.5]);
|
||||
assert.equal(termBounds({ id: 9, name: 'N/A', slug: 'zzz' }), null);
|
||||
});
|
||||
|
||||
test('deriveThicknessBandId: half-open [low, high) — boundary belongs to the starting band', () => {
|
||||
const terms: ThicknessTerm[] = [
|
||||
{ id: 10, name: '3/4"–1"' },
|
||||
{ id: 11, name: '1"–1 1/2"' },
|
||||
{ id: 12, name: '1 1/2"–3"' },
|
||||
];
|
||||
assert.equal(deriveThicknessBandId(0.75, terms), 10);
|
||||
assert.equal(deriveThicknessBandId(0.9, terms), 10);
|
||||
// Exactly on the 1" shared boundary → the band that starts at 1".
|
||||
assert.equal(deriveThicknessBandId(1, terms), 11);
|
||||
assert.equal(deriveThicknessBandId(1.5, terms), 12);
|
||||
assert.equal(deriveThicknessBandId(2.5, terms), 12);
|
||||
});
|
||||
|
||||
test('deriveThicknessBandId: 1/8" steps land in the enclosing band', () => {
|
||||
const terms: ThicknessTerm[] = [
|
||||
{ id: 39, name: '2-4 in' },
|
||||
{ id: 10, name: '1-2 in' },
|
||||
];
|
||||
assert.equal(deriveThicknessBandId(1, terms), 10);
|
||||
assert.equal(deriveThicknessBandId(1.5, terms), 10);
|
||||
assert.equal(deriveThicknessBandId(2, terms), 39); // 2 is the upper bound of 1-2 → excluded, falls into 2-4
|
||||
assert.equal(deriveThicknessBandId(3.875, terms), 39);
|
||||
});
|
||||
|
||||
test('deriveThicknessBandId: order-independent, gap fallback by nearest lower edge', () => {
|
||||
const terms: ThicknessTerm[] = [
|
||||
{ id: 12, name: '1 1/2"–3"' },
|
||||
{ id: 11, name: '1"–1 1/2"' },
|
||||
];
|
||||
assert.equal(deriveThicknessBandId(1.25, terms), 11);
|
||||
// Gap below 1": nearest band by lower edge.
|
||||
assert.equal(deriveThicknessBandId(0.5, terms), 11);
|
||||
});
|
||||
|
||||
test('deriveThicknessBandId: unbounded "over" band catches large values', () => {
|
||||
const terms: ThicknessTerm[] = [
|
||||
{ id: 1, name: '3/4"–1"' },
|
||||
{ id: 2, name: '1"+' },
|
||||
];
|
||||
assert.equal(deriveThicknessBandId(12, terms), 2);
|
||||
});
|
||||
|
||||
test('deriveThicknessBandId: null / invalid input → null (no invented band)', () => {
|
||||
const terms: ThicknessTerm[] = [{ id: 1, name: '1-2 in' }];
|
||||
assert.equal(deriveThicknessBandId(null, terms), null);
|
||||
assert.equal(deriveThicknessBandId(undefined, terms), null);
|
||||
assert.equal(deriveThicknessBandId(-1, terms), null);
|
||||
assert.equal(deriveThicknessBandId(Number.NaN, terms), null);
|
||||
assert.equal(deriveThicknessBandId(1, []), null);
|
||||
assert.equal(deriveThicknessBandId(1, [{ id: 1, name: 'N/A' }]), null);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Deterministic thickness-band derivation (METADATA-15 / QUALITY-95).
|
||||
*
|
||||
* Mirrors the backend's `resolve_thickness_band_id` so the Review screen can
|
||||
* preview the band the server will store: the owner enters thickness in 1/8"
|
||||
* steps and the band follows — the UI never exposes a band picker. Bands are
|
||||
* parsed from the live synced term names/slugs, never hardcoded ids.
|
||||
*
|
||||
* Round-up semantics: bands are half-open on the upper bound, so a value on a
|
||||
* shared boundary belongs to the band that *starts* there ("1"–1 1/2" wins over
|
||||
* "3/4"–1").
|
||||
*/
|
||||
|
||||
export interface ThicknessTerm {
|
||||
id: number;
|
||||
name: string;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
/** Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → inches. */
|
||||
export function parseInchToken(text: string | undefined | null): number | null {
|
||||
const t = (text ?? '').trim().replace(/["']\s*$/, '').trim();
|
||||
if (!t) return null;
|
||||
const m =
|
||||
t.match(/^(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))$/i) ??
|
||||
t.match(/(?:(\d+)\s+(\d+)\s*\/\s*(\d+)|(\d+)\s*\/\s*(\d+)|(\d+(?:\.\d+)?))/i);
|
||||
if (!m) return null;
|
||||
if (m[1] != null) return Number(m[1]) + Number(m[2]) / Number(m[3]);
|
||||
if (m[4] != null) return Number(m[4]) / Number(m[5]);
|
||||
if (m[6] != null) return Number(m[6]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Half-open [low, high) inches from one synced term; name first, slug fallback. */
|
||||
export function termBounds(term: ThicknessTerm): [number, number] | null {
|
||||
return boundsFromName(String(term.name ?? '')) ?? boundsFromSlug(String(term.slug ?? ''));
|
||||
}
|
||||
|
||||
function boundsFromName(raw: string): [number, number] | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
const low = text.toLowerCase();
|
||||
|
||||
// Under / below / less-than / < X
|
||||
let m = low.match(/^(?:under|below|less than|<)\s*(.+)$/);
|
||||
if (m) {
|
||||
const hi = parseInchToken(m[1]);
|
||||
if (hi != null) return [0, hi];
|
||||
}
|
||||
// Over / above / greater-than / > X / X+
|
||||
m = low.match(/^(?:over|above|greater than|>)\s*(.+)$/);
|
||||
if (m) {
|
||||
const lo = parseInchToken(m[1]);
|
||||
if (lo != null) return [lo, Infinity];
|
||||
}
|
||||
m = text.match(/^(.+?)\+\s*$/);
|
||||
if (m) {
|
||||
const lo = parseInchToken(m[1]);
|
||||
if (lo != null) return [lo, Infinity];
|
||||
}
|
||||
// Range: "A - B" (hyphen / en / em dash, quotes optional on either side)
|
||||
const parts = text.split(/\s*[-–—]\s*/);
|
||||
if (parts.length === 2) {
|
||||
const lo = parseInchToken(parts[0]);
|
||||
const hi = parseInchToken(parts[1]);
|
||||
if (lo != null && hi != null && hi > lo) return [lo, hi];
|
||||
}
|
||||
// Single value band, treated as [v, v+ε) — rare
|
||||
const only = parseInchToken(text);
|
||||
if (only != null) return [only, only + 1e-9];
|
||||
return null;
|
||||
}
|
||||
|
||||
function boundsFromSlug(slug: string): [number, number] | null {
|
||||
const s = slug.trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
if (s === 'xthin' || s === 'under' || s === 'thin') return [0, 0.5];
|
||||
const range = s.match(/^(\d+)-(\d+)qtr$/);
|
||||
if (range) {
|
||||
const lo = Number(range[1]) * 0.25;
|
||||
const hi = Number(range[2]) * 0.25;
|
||||
if (hi > lo) return [lo, hi];
|
||||
}
|
||||
const min = s.match(/^(\d+)qtr$/);
|
||||
if (min) return [Number(min[1]) * 0.25, Infinity];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the synced thickness term id for `thicknessIn`, or null when unknown
|
||||
* (no thickness, no parseable bands, negative value).
|
||||
*/
|
||||
export function deriveThicknessBandId(
|
||||
thicknessIn: number | null | undefined,
|
||||
terms: ThicknessTerm[],
|
||||
): number | null {
|
||||
if (thicknessIn == null) return null;
|
||||
const t = Number(thicknessIn);
|
||||
if (!Number.isFinite(t) || t < 0) return null;
|
||||
|
||||
const scored: [number, number, number][] = [];
|
||||
for (const term of terms) {
|
||||
const b = termBounds(term);
|
||||
if (!b) continue;
|
||||
if (term.id == null) continue;
|
||||
scored.push([b[0], b[1], Number(term.id)]);
|
||||
}
|
||||
if (!scored.length) return null;
|
||||
|
||||
// Prefer the tightest band that contains t under half-open [low, high).
|
||||
const matches = scored.filter(([lo, hi]) => lo <= t && t < hi).map(([lo, hi, id]) => [hi - lo, lo, hi, id] as const);
|
||||
if (matches.length) {
|
||||
matches.sort((a, z) => a[0] - z[0]);
|
||||
return Number(matches[0][3]);
|
||||
}
|
||||
// Gaps in the taxonomy: pick the nearest band by lower edge.
|
||||
scored.sort((a, z) => a[0] - z[0]);
|
||||
for (const [lo, hi, id] of scored) {
|
||||
if (t < hi) return id;
|
||||
}
|
||||
return Number(scored[scored.length - 1][2]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
|
||||
test('nextPrimary: empty selection clears primary', () => {
|
||||
assert.equal(nextPrimary([], 21), '');
|
||||
assert.equal(nextPrimary([], ''), '');
|
||||
});
|
||||
|
||||
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||
assert.equal(nextPrimary([21, 22], null), 21);
|
||||
});
|
||||
|
||||
test('nextPrimary: keeps current when still selected', () => {
|
||||
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||
assert.equal(nextPrimary([22], 22), 22);
|
||||
});
|
||||
|
||||
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||
assert.equal(nextPrimary([23], 21), 23);
|
||||
});
|
||||
|
||||
test('selectedSummary: placeholder when empty', () => {
|
||||
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||
});
|
||||
|
||||
test('selectedSummary: shows names, then +N overflow', () => {
|
||||
const m = new Map([
|
||||
[1, 'Live Edge'],
|
||||
[2, 'Book Match'],
|
||||
[3, 'Cookie'],
|
||||
]);
|
||||
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||
});
|
||||
|
||||
test('selectedSummary: falls back to count when names unknown', () => {
|
||||
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* REVIEW-15: the owner declares one primary wood category among the selected
|
||||
* set. Default primary = first selected. Deselecting the current primary
|
||||
* promotes the first remaining selected id. Empty selection clears primary.
|
||||
*/
|
||||
export function nextPrimary(
|
||||
selected: number[],
|
||||
current: number | '' | null | undefined,
|
||||
): number | '' {
|
||||
if (!selected.length) return '';
|
||||
const cur = typeof current === 'number' ? current : '';
|
||||
if (cur !== '' && selected.includes(cur)) return cur;
|
||||
return selected[0];
|
||||
}
|
||||
|
||||
/** Closed MultiSelect summary: names when known, else a count. */
|
||||
export function selectedSummary(
|
||||
selected: number[],
|
||||
nameById: Map<number, string>,
|
||||
placeholder: string,
|
||||
maxNames = 2,
|
||||
): string {
|
||||
if (!selected.length) return placeholder;
|
||||
const names = selected
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((n): n is string => !!n && n.length > 0);
|
||||
if (!names.length) {
|
||||
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||
}
|
||||
if (names.length <= maxNames) return names.join(', ');
|
||||
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||
}
|
||||
Reference in New Issue
Block a user