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
This commit is contained in:
grokbot_ty
2026-09-26 09:41:30 -07:00
parent c54870f5b9
commit 85786f16f2
13 changed files with 214 additions and 85 deletions
+25 -7
View File
@@ -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)
+26 -6
View File
@@ -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]
+1 -1
View File
@@ -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 | — |
+1 -1
View File
@@ -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`).
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+10
View File
@@ -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 &lt; B &gt; C'), 'A < B > C');
assert.equal(decodeName('say &quot;hi&quot; &#39;there&#39;'), 'say "hi" \'there\'');
assert.equal(decodeName('plain'), 'plain');
});
+9
View File
@@ -0,0 +1,9 @@
/** Decode common HTML entities Woo puts in taxonomy display names. */
export function decodeName(name: string): string {
return name
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
+30 -58
View File
@@ -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 @@
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
<div class="field" data-testid="wood-cat-field">
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
<label class="lbl">Wood categories</label>
<MultiSelect
label="Wood categories"
groups={woodCatGroupsView}
@@ -703,7 +692,6 @@
>
<span class="ps-radio" aria-hidden="true"></span>
<span class="ps-name">{o.name}</span>
{#if o.ai}<span class="ps-tag">AI</span>{/if}
</button>
{/each}
<p class="ps-note">
@@ -714,9 +702,9 @@
{/if}
</div>
<!-- Edge type (single, AI-assist) -->
<!-- Edge type (single) -->
<div class="field">
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
<label class="lbl" for="edge">Edge type</label>
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
<option value="">— select —</option>
{#each edgeOpts as o (o.id)}
@@ -725,9 +713,9 @@
</select>
</div>
<!-- Figure (multi, searchable list, AI-assist) -->
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
<div class="field">
<label class="lbl">Figure <span class="ai">AI assist</span></label>
<label class="lbl">Figure</label>
<MultiSelect
label="Figure"
options={figureOptsView}
@@ -748,28 +736,14 @@
</select>
</div>
<!-- fig-* product tags (optional, owner-only, no AI) -->
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
<div class="field">
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
<label class="lbl">Features <span class="sug">optional</span></label>
<MultiSelect
label="fig-* tags"
options={figTagOpts}
selected={figTagIds}
placeholder="Choose fig-* tags"
testid="fig-tag"
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
on:commit={commitReview}
/>
</div>
<!-- feat-* product tags (optional; Call 1 assist disabled on this branch) -->
<div class="field">
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
<MultiSelect
label="feat-* tags"
label="Features"
options={featTagOptsView}
selected={featTagIds}
placeholder="Choose feat-* tags"
placeholder="Choose features"
testid="feat-tag"
onToggle={(id) => (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; }
@@ -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');
});
+33
View File
@@ -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<T extends { slug: string }>(tags: T[]): T[] {
return tags.filter((t) => t.slug.startsWith('fig-'));
}
export function scopedFeatTags<T extends { slug: string }>(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);
}
@@ -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);
});
+1 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { decodeName } from '../decodeName';
import { onMount } from 'svelte';
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
@@ -52,16 +53,6 @@
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
function decodeName(name: string): string {
// Woo sometimes returns HTML entities in category names.
return name
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function loadRates() {
ratesLoading = true;
ratesMessage = '';