From 85786f16f2ac3f73b3f2089aed6d8e2d7ee28e0c Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 09:41:30 -0700 Subject: [PATCH] fix(review): slug-scoped tags, decode names, drop fig picker & AI labels - Taxonomy sync keeps fig-/feat- by slug (hyphen), not name; HTML-unescape names - Review: remove AI assist labels; Features picker for feat-*; derive fig_tag_ids from Figure term names (casefold match, no bridges); decodeName on display - Unit tests for scoped_tags, fig match, Review UI contract; docs slug wording --- backend/taxonomy.py | 32 +++++-- backend/tests/test_woo.py | 32 +++++-- docs/REQUIREMENTS.md | 2 +- docs/capabilities/CREATE.md | 2 +- docs/contracts/API.md | 2 +- docs/contracts/DATA-MODEL.md | 2 +- frontend/src/create/decodeName.test.ts | 10 +++ frontend/src/create/decodeName.ts | 9 ++ frontend/src/create/review/Review.svelte | 88 +++++++------------ frontend/src/create/review/figTags.test.ts | 42 +++++++++ frontend/src/create/review/figTags.ts | 33 +++++++ .../create/review/reviewUi.contract.test.ts | 34 +++++++ frontend/src/create/settings/Settings.svelte | 11 +-- 13 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 frontend/src/create/decodeName.test.ts create mode 100644 frontend/src/create/decodeName.ts create mode 100644 frontend/src/create/review/figTags.test.ts create mode 100644 frontend/src/create/review/figTags.ts create mode 100644 frontend/src/create/review/reviewUi.contract.test.ts diff --git a/backend/taxonomy.py b/backend/taxonomy.py index beb6d1f..ed294bd 100644 --- a/backend/taxonomy.py +++ b/backend/taxonomy.py @@ -1,11 +1,14 @@ """Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache. Rebuild happens in one transaction; on any failure the prior cache is kept. -Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a -stable hash of the stored subset, recorded only when it changes. +Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen +(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored +subset, recorded only when it changes. Display names are HTML-unescaped so +Woo entities like ``&`` store as ``&``. """ import hashlib +import html import json from datetime import datetime, timezone @@ -16,8 +19,23 @@ def _now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() +def _decode_name(name: str | None) -> str: + """Decode HTML entities Woo puts in taxonomy display names.""" + return html.unescape(name or "") + + def _scoped_tags(tags: list[dict]) -> list[dict]: - return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))] + """Keep product tags whose slug starts with fig- or feat- (hyphen required). + + Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl. + Filtering by name prefix kept 0 live tags; slug is authoritative. + """ + out = [] + for t in tags: + slug = t.get("slug") or "" + if slug.startswith("fig-") or slug.startswith("feat-"): + out.append(t) + return out def _normalize_category(c: dict) -> tuple: @@ -34,7 +52,7 @@ def _normalize_category(c: dict) -> tuple: woo_id = c.get("woo_id", c.get("id")) if woo_id is not None: woo_id = int(woo_id) - return (c["id"], c["name"], c["slug"], parent_id, woo_id) + return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id) def _subset_hash(categories: list[dict], attributes: list[dict], @@ -90,16 +108,16 @@ def sync(client: WooClient, conn) -> dict: conn.executemany( "INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)", - [(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes], + [(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes], ) for attr_id, terms in terms_by_attr.items(): conn.executemany( "INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)", - [(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms], + [(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms], ) conn.executemany( "INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)", - [(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags], + [(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags], ) anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags) diff --git a/backend/tests/test_woo.py b/backend/tests/test_woo.py index d3ab93d..004ed8b 100644 --- a/backend/tests/test_woo.py +++ b/backend/tests/test_woo.py @@ -95,12 +95,29 @@ def test_401_raises_auth_error(db_dir): c.get_categories() +def test_scoped_tags_by_slug_not_name(): + """SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay.""" + tags = [ + {"id": 1, "name": "Curl", "slug": "fig-curl"}, + {"id": 2, "name": "Knot", "slug": "feat-knot"}, + {"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop + {"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop + {"id": 5, "name": "other", "slug": "other-tag"}, + ] + kept = taxonomy_mod._scoped_tags(tags) + assert {t["id"] for t in kept} == {1, 2} + + def test_sync_populates_scoped(db_dir): - cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode()) + # Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0. + cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([ + {"id": 1, "name": "Cookies & Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10}, + ]).encode()) tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([ - {"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20}, - {"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21}, - {"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped + {"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20}, + {"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21}, + {"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop + {"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23}, ]).encode()) attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode()) terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode()) @@ -108,8 +125,11 @@ def test_sync_populates_scoped(db_dir): result = taxonomy_mod.sync(c, db_mod.get_connection()) conn = db_mod.get_connection() assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1] - tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")} - assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped + assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices" + tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")} + assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped + assert tag_rows[2] == ("Curl", "fig-curl") + assert tag_rows[3] == ("Knot", "feat-knot") assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6] assert result["taxonomy_anchor"] assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0] diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 96e60e9..84cec29 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — | | SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — | | SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — | -| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — | +| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py | | SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — | | SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — | | SETTINGS-65 | woo_create_status defaults to draft. | M3 | — | diff --git a/docs/capabilities/CREATE.md b/docs/capabilities/CREATE.md index 1c6f5f5..a834f88 100644 --- a/docs/capabilities/CREATE.md +++ b/docs/capabilities/CREATE.md @@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (` test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only -(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds +(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and publish refreshes when the last sync is over an hour old (`SETTINGS-60`). diff --git a/docs/contracts/API.md b/docs/contracts/API.md index ddf6f54..33dbe9a 100644 --- a/docs/contracts/API.md +++ b/docs/contracts/API.md @@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS ## 8. Taxonomy - `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus - `taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept. + `taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept. These lists are the only legal picker options. - `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets `taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and diff --git a/docs/contracts/DATA-MODEL.md b/docs/contracts/DATA-MODEL.md index 9fe6e0f..841f1bd 100644 --- a/docs/contracts/DATA-MODEL.md +++ b/docs/contracts/DATA-MODEL.md @@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55. - `woo_categories` — species and wood-category leaves only; parents group and are never assigned. - `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure, Grade, Thickness, Moisture, with their terms. -- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored. +- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored. Rebuild is one transaction; on failure the previous cache is kept. diff --git a/frontend/src/create/decodeName.test.ts b/frontend/src/create/decodeName.test.ts new file mode 100644 index 0000000..3735e4c --- /dev/null +++ b/frontend/src/create/decodeName.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { decodeName } from './decodeName'; + +test('decodeName: ampersand and common entities', () => { + assert.equal(decodeName('Cookies & Burl Slices'), 'Cookies & Burl Slices'); + assert.equal(decodeName('A < B > C'), 'A < B > C'); + assert.equal(decodeName('say "hi" 'there''), 'say "hi" \'there\''); + assert.equal(decodeName('plain'), 'plain'); +}); diff --git a/frontend/src/create/decodeName.ts b/frontend/src/create/decodeName.ts new file mode 100644 index 0000000..d4fe2bc --- /dev/null +++ b/frontend/src/create/decodeName.ts @@ -0,0 +1,9 @@ +/** Decode common HTML entities Woo puts in taxonomy display names. */ +export function decodeName(name: string): string { + return name + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} diff --git a/frontend/src/create/review/Review.svelte b/frontend/src/create/review/Review.svelte index 2f2d07a..2924ca9 100644 --- a/frontend/src/create/review/Review.svelte +++ b/frontend/src/create/review/Review.svelte @@ -8,7 +8,8 @@ import type { CalibratedCapture } from '../flow'; import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture'; import MultiSelect from './MultiSelect.svelte'; - import { deriveThicknessTermId } from './thicknessBand'; + import { decodeName } from '../decodeName'; + import { deriveFigTagIds, scopedFeatTags } from './figTags'; import { keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl, type LineupItem, @@ -41,7 +42,6 @@ let figureOpts: Opt[] = []; let gradeOpts: Opt[] = []; let moistureOpts: Opt[] = []; - let figTagOpts: Opt[] = []; let featTagOpts: Opt[] = []; function deriveOptions() { @@ -63,7 +63,7 @@ // attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined. const m = t.name.match(/\d+(?:[./]\d+)?/); const lowIn = m ? Number(m[0]) : undefined; - return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined }; + return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined }; }); }; // Wood categories: parent/child grouped (METADATA-10 — parents group only, never @@ -73,9 +73,9 @@ woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => { const kids = cats.filter((c) => c.parent_id === parent.id); const leaves: Opt[] = kids.length - ? kids.map((c) => ({ id: c.id, name: c.name, ai: false })) - : [{ id: parent.id, name: parent.name, ai: false }]; - return { parent: { id: parent.id, name: parent.name }, leaves }; + ? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false })) + : [{ id: parent.id, name: decodeName(parent.name), ai: false }]; + return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves }; }); edgeOpts = termsOf('edge type'); figureOpts = termsOf('figure'); @@ -83,12 +83,12 @@ // 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 - // store has none, the picker is legitimately empty. + // feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are + // derived from Figure attribute selections (no separate picker). const tags: TaxTag[] = tax.tags ?? []; - figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false })); - featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false })); + featTagOpts = scopedFeatTags(tags).map((t) => ({ + id: t.id, name: decodeName(t.name), ai: false, + })); } // --- Editable model (mirrors the live slab) --- @@ -148,9 +148,6 @@ let genBusy = false; let genError = ''; let canGenerateContent = false; - let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles) - - const num = (v: unknown) => (typeof v === 'number' ? v : null); const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; }; const fmt2 = (v: number) => v.toFixed(2); @@ -327,7 +324,6 @@ figureIds = [...(s.figure_term_ids ?? [])]; gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : ''; moistureId = s.moisture_term_id ?? ''; - figTagIds = [...(s.fig_tag_ids ?? [])]; featTagIds = [...(s.feat_tag_ids ?? [])]; title = s.title ?? ''; shortDescription = s.short_description ?? ''; @@ -338,36 +334,29 @@ sku = s.sku ?? ''; // REVIEW-15: primary defaults to first selected wood cat; keep if still valid. primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? ''); - // Seed AI-recommended markers from stored confidences (>=0.7 means the AI put - // its weight behind the value the owner kept). - aiRecommendations = []; - if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id) - aiRecommendations.push(s.species_id); - if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7) - aiRecommendations.push(...(s.figure_term_ids ?? [])); + // fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags). } - // AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch). - $: woodCatGroupsView = woodCatGroups.map((g) => ({ - parent: g.parent, - leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })), - })); - $: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); - $: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); + // Call 1 out: no AI badges on options. Groups/opts pass through as-is. + $: woodCatGroupsView = woodCatGroups; + $: figureOptsView = figureOpts; + $: featTagOptsView = featTagOpts; + // fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold). + $: figTagIds = tax + ? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? []) + : figTagIds; // Flat leaf name lookup for the primary radio set (REVIEW-15). $: woodLeafById = new Map( woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])), ); $: primaryOpts = woodCatIds .map((id) => woodLeafById.get(id)) - .filter((o): o is Opt => !!o) - .map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })); + .filter((o): o is Opt => !!o); // METADATA-15 / QUALITY-95: the thickness 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 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. // #89: the deterministic title/short_description are written here — the server's // review returns the build from the current metadata, and if the field is empty @@ -678,7 +667,7 @@
- + {o.name} - {#if o.ai}AI{/if} {/each}

@@ -714,9 +702,9 @@ {/if}

- +
- +
- +
- +
- +
- + (figTagIds = toggleMulti(figTagIds, id))} - on:commit={commitReview} - /> -
- - -
- - (featTagIds = toggleMulti(featTagIds, id))} on:commit={commitReview} @@ -875,7 +849,6 @@ .math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; } .math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; } .lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; } - .lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; } /* box-sizing: the app has no global reset, so a width:100% control with padding and a border pokes out of its container by that padding+border (20px here) — which becomes real page overflow whenever the column sits at the viewport edge @@ -908,7 +881,6 @@ .ps-opt.on .ps-radio { border-color: #00e5ff; } .ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; } .ps-name { flex: 1; } - .ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; } .ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; } .status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; } .status.ok { background: #122a12; color: #7fd37f; } diff --git a/frontend/src/create/review/figTags.test.ts b/frontend/src/create/review/figTags.test.ts new file mode 100644 index 0000000..68f289f --- /dev/null +++ b/frontend/src/create/review/figTags.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + deriveFigTagIds, + normName, + scopedFeatTags, + scopedFigTags, +} from './figTags'; + +test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => { + const tags = [ + { id: 1, name: 'Curl', slug: 'fig-curl' }, + { id: 2, name: 'Knot', slug: 'feat-knot' }, + { id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out + { id: 4, name: 'Featured', slug: 'featured' }, + { id: 5, name: 'fig-fake-name', slug: 'other' }, + ]; + assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]); + assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]); +}); + +test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => { + const terms = [ + { id: 10, name: 'Curl' }, + { id: 11, name: ' Birdseye ' }, + ]; + const tags = [ + { id: 100, name: 'curl', slug: 'fig-curl' }, + { id: 101, name: 'Curly', slug: 'fig-curly' }, + { id: 102, name: 'Birdseye', slug: 'fig-birdseye' }, + { id: 103, name: 'Knot', slug: 'feat-knot' }, + ]; + assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]); + assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]); + // Curly tag stays unmatched when only Curl is selected + assert.ok(!deriveFigTagIds([10], terms, tags).includes(101)); + assert.deepEqual(deriveFigTagIds([], terms, tags), []); +}); + +test('normName strips and lowercases', () => { + assert.equal(normName(' Curl '), 'curl'); +}); diff --git a/frontend/src/create/review/figTags.ts b/frontend/src/create/review/figTags.ts new file mode 100644 index 0000000..0ff8c04 --- /dev/null +++ b/frontend/src/create/review/figTags.ts @@ -0,0 +1,33 @@ +/** + * Derive fig-* product tag ids from selected Figure attribute terms. + * Match is case-insensitive name equality (casefold/strip) against tags whose + * slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly). + */ +export function normName(name: string): string { + // JS has no String.casefold; toLowerCase covers ASCII wood-term names. + return name.trim().toLowerCase(); +} + +export function scopedFigTags(tags: T[]): T[] { + return tags.filter((t) => t.slug.startsWith('fig-')); +} + +export function scopedFeatTags(tags: T[]): T[] { + return tags.filter((t) => t.slug.startsWith('feat-')); +} + +export function deriveFigTagIds( + figureTermIds: number[], + figureTerms: { id: number; name: string }[], + tags: { id: number; name: string; slug: string }[], +): number[] { + const selected = new Set( + figureTerms + .filter((t) => figureTermIds.includes(t.id)) + .map((t) => normName(t.name)), + ); + if (selected.size === 0) return []; + return scopedFigTags(tags) + .filter((t) => selected.has(normName(t.name))) + .map((t) => t.id); +} diff --git a/frontend/src/create/review/reviewUi.contract.test.ts b/frontend/src/create/review/reviewUi.contract.test.ts new file mode 100644 index 0000000..ea66057 --- /dev/null +++ b/frontend/src/create/review/reviewUi.contract.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const dir = dirname(fileURLToPath(import.meta.url)); +const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8'); +const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8'); + +test('Review UI: no AI assist labels or AI badge chrome', () => { + assert.equal(reviewSrc.includes('AI assist'), false); + assert.equal(reviewSrc.includes('class="ai"'), false); + assert.equal(reviewSrc.includes('ps-tag'), false); +}); + +test('Review UI: no fig-* MultiSelect picker', () => { + assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false); + assert.equal(reviewSrc.includes('figTagOpts'), false); + assert.equal(reviewSrc.includes('label="fig-* tags"'), false); + assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false); + assert.match(reviewSrc, /deriveFigTagIds/); + assert.match(reviewSrc, /Features/); + assert.match(reviewSrc, /testid="feat-tag"/); +}); + +test('Tag family scope uses hyphenated slug prefix', () => { + assert.match(figSrc, /startsWith\('fig-'\)/); + assert.match(figSrc, /startsWith\('feat-'\)/); + assert.equal(/startsWith\('fig'\)/.test(figSrc), false); + assert.equal(/startsWith\('feat'\)/.test(figSrc), false); + assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false); + assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false); +}); diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index 95bf0d2..4ea7f72 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -1,4 +1,5 @@