Add cross-entity search to v2 client

Implements the search feature for client_v2: a new backend search endpoint
plus a results-panel UI, replacing the old query box that only filtered the
library list by filament name.

Backend (additive, API v1 backward compatibility preserved):
- New GET /api/v1/search returning categorized spool/filament/vendor results,
  each annotated with the field that matched, plus an is_color_query flag.
- Case-insensitive text search over native text fields and text/choice extra
  fields; exact spool-id match for numeric queries; color-similarity search
  (CSS color name or hex) via find_by_color, ordered closest first.
- spoolman/colors.py resolves the CSS4 color names / hex codes to hex.
- Integration tests (run across SQLite/PostgreSQL/MySQL/CockroachDB in CI).

Frontend (client_v2):
- SearchBox overlay with Spools/Filaments/Manufacturers sections, per-result
  match badges, keyboard navigation, a color-similarity threshold slider
  (persisted to localStorage), and an English-only color-name hint.
- Clicking a result opens the matching inspector on the Library page.
- Removes the old q -> filament.name inline list filtering.

Claude-Session: https://claude.ai/code/session_01Ne7RsfHejKZX97uAq3aVMn
This commit is contained in:
Donkie committed 2026-07-13 19:33:52 +02:00
1 parent 4bdebbdded
commit 7ea89dc347
16 files changed
+1345 -37

No files matched your search

-3
View File
@@ -44,7 +44,6 @@ export function buildGroupQuery(state: LibraryState): GroupQuery {
return {
field,
filters: currentFilters(state),
search: state.query.trim() || undefined,
sort: [{ field: groupField, dir }],
limit: state.pageSize,
offset: (state.page - 1) * state.pageSize,
@@ -62,7 +61,6 @@ export function buildScopedSpoolQuery(state: LibraryState, group: GroupSummary,
return {
filters: currentFilters(state),
sort,
search: state.query.trim() || undefined,
groupScope: { field: group.field, key: group.key },
limit,
offset: 0,
@@ -79,7 +77,6 @@ export function buildFlatSpoolQuery(state: LibraryState): SpoolQuery {
return {
filters: currentFilters(state),
sort,
search: state.query.trim() || undefined,
limit: state.pageSize,
offset: (state.page - 1) * state.pageSize,
lowThreshold: settings.lowThreshold
+64
View File
@@ -0,0 +1,64 @@
import type { SearchMatch, SearchResults } from './types';
import type { Filament, Spool, Vendor } from '$lib/types';
import { API_BASE } from './config';
import { mapFilament, mapSpool, mapVendor } from './map';
import { inventory } from '$lib/stores/inventory.svelte';
// Cross-entity search against GET /api/v1/search. Like the spool source, every
// entity it fetches is upserted into the reactive cache so that clicking a result
// (which opens its inspector) has the data ready immediately.
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
const EMPTY: SearchResults = { spools: [], filaments: [], vendors: [], isColorQuery: false };
function mapSpoolHit(m: Json): SearchMatch<Spool> {
const raw: Json = m.spool ?? {};
if (raw.filament) {
inventory.upsertFilament(mapFilament(raw.filament));
if (raw.filament.vendor) inventory.upsertVendor(mapVendor(raw.filament.vendor));
}
const spool = mapSpool(raw);
inventory.upsertSpool(spool);
return { entity: spool, matchField: m.match_field };
}
function mapFilamentHit(m: Json): SearchMatch<Filament> {
const raw: Json = m.filament ?? {};
if (raw.vendor) inventory.upsertVendor(mapVendor(raw.vendor));
const filament = mapFilament(raw);
inventory.upsertFilament(filament);
return { entity: filament, matchField: m.match_field };
}
function mapVendorHit(m: Json): SearchMatch<Vendor> {
const vendor = mapVendor(m.vendor ?? {});
inventory.upsertVendor(vendor);
return { entity: vendor, matchField: m.match_field };
}
/**
* Run a cross-entity search. `threshold` is the color-similarity threshold used
* when the query is a color. Pass an AbortSignal to cancel a superseded request.
*/
export async function searchAll(
query: string,
threshold: number,
signal?: AbortSignal
): Promise<SearchResults> {
const q = query.trim();
if (!q) return EMPTY;
const params = new URLSearchParams({ q, color_similarity_threshold: String(threshold) });
const res = await fetch(`${API_BASE}/search?${params.toString()}`, { signal });
if (!res.ok) throw new Error(`GET /search → ${res.status}`);
const data = (await res.json()) as Json;
return {
spools: (data.spools ?? []).map(mapSpoolHit),
filaments: (data.filaments ?? []).map(mapFilamentHit),
vendors: (data.vendors ?? []).map(mapVendorHit),
isColorQuery: !!data.is_color_query
};
}
-2
View File
@@ -98,7 +98,6 @@ class HttpSpoolSource {
offset: query.offset
};
applyFilters(params, query.filters);
if (query.search) params['filament.name'] = query.search;
const { items, total } = await getList('/spool/group', params);
for (const g of items as Json[]) {
@@ -119,7 +118,6 @@ class HttpSpoolSource {
};
applyFilters(params, query.filters);
scopeParams(params, query.groupScope);
if (query.search) params['filament.name'] = query.search;
const { items, total } = await getList('/spool', params);
return { items: cacheSpools(items), total };
+23 -5
View File
@@ -1,4 +1,4 @@
import type { Spool } from '$lib/types';
import type { Filament, Spool, Vendor } from '$lib/types';
// The query/data-source contract. Filtering, sorting, aggregation and
// pagination are the SERVER's job. Two shapes of list:
@@ -47,9 +47,6 @@ export interface SpoolQuery {
/** field path -> allowed values (OR within a field, AND across fields). */
filters: Record<string, string[]>;
sort: SortField[];
search?: string;
/** Hex color for similarity ordering (server-side vector search). */
color?: string;
/** Restrict to one group's spools (for lazy per-group loading). */
groupScope?: GroupScope;
limit: number;
@@ -60,7 +57,6 @@ export interface SpoolQuery {
export interface GroupQuery {
field: GroupField;
filters: Record<string, string[]>;
search?: string;
/** Group-level ordering (by aggregate: group.total_remaining, group.last_used, group.title). */
sort: SortField[];
limit: number;
@@ -74,6 +70,28 @@ export interface Page<T> {
total: number;
}
// --- cross-entity search (GET /search) -----------------------------------
/** One search hit: the matched entity plus which field matched it. */
export interface SearchMatch<T> {
entity: T;
/**
* Which field matched: a native field name ("name", "comment", "location",
* "lot_nr", "material", "article_number"), "id" for an exact spool id, "color"
* for a color-similarity match, or "extra.<key>" for an extra field.
*/
matchField: string;
}
/** Categorized results of a cross-entity search. */
export interface SearchResults {
spools: SearchMatch<Spool>[];
filaments: SearchMatch<Filament>[];
vendors: SearchMatch<Vendor>[];
/** True when the query was recognized as a color (a threshold slider is relevant). */
isColorQuery: boolean;
}
export interface SpoolDataSource {
listGroups(query: GroupQuery): Promise<Page<GroupSummary>>;
listSpools(query: SpoolQuery): Promise<Page<Spool>>;
@@ -5,10 +5,12 @@
value: string;
placeholder?: string;
oninput?: (value: string) => void;
onkeydown?: (e: KeyboardEvent) => void;
onfocus?: () => void;
fullWidth?: boolean;
}
let { value, placeholder = '', oninput, fullWidth = false }: Props = $props();
let { value, placeholder = '', oninput, onkeydown, onfocus, fullWidth = false }: Props = $props();
</script>
<div class="search" class:full={fullWidth}>
@@ -17,6 +19,8 @@
{value}
{placeholder}
oninput={(e) => oninput?.(e.currentTarget.value)}
onkeydown={(e) => onkeydown?.(e)}
onfocus={() => onfocus?.()}
aria-label={$_('common.search')}
/>
</div>
+3 -18
View File
@@ -1,10 +1,8 @@
<script lang="ts">
import Logo from './Logo.svelte';
import NavTabs from './NavTabs.svelte';
import SearchInput from './SearchInput.svelte';
import SearchBox from './library/SearchBox.svelte';
import Button from './Button.svelte';
import { setQuery } from '$lib/library/params';
import { page } from '$app/state';
import { _ } from 'svelte-i18n';
interface Props {
@@ -13,10 +11,6 @@
}
let { onadd, onscan }: Props = $props();
// The search box lives in the layout (above the routed page), so it reads the
// query straight from the URL and writes it back through the params helper.
let query = $derived(page.url.searchParams.get('q') ?? '');
</script>
<header class="topbar">
@@ -44,11 +38,7 @@
</svg>
</button>
<div class="search-desktop">
<SearchInput
value={query}
placeholder={'⌕ ' + $_('topbar.search_placeholder')}
oninput={(v) => setQuery(v)}
/>
<SearchBox />
</div>
<div class="add-desktop">
<Button onclick={onadd}>+ {$_('topbar.add_spools')}</Button>
@@ -57,12 +47,7 @@
<!-- Mobile: search + nav collapse onto their own rows -->
<div class="row mobile-search">
<SearchInput
value={query}
placeholder={$_('topbar.search_placeholder')}
oninput={(v) => setQuery(v)}
fullWidth
/>
<SearchBox fullWidth />
</div>
<div class="row mobile-nav">
<NavTabs />
@@ -0,0 +1,390 @@
<script lang="ts">
import SearchInput from '../SearchInput.svelte';
import Swatch from '../Swatch.svelte';
import MaterialBadge from '../MaterialBadge.svelte';
import { searchAll } from '$lib/api/search';
import type { SearchResults } from '$lib/api/types';
import { openSearchResult } from '$lib/library/params';
import { inventory } from '$lib/stores/inventory.svelte';
import { fields } from '$lib/stores/fields.svelte';
import type { EntityKind } from '$lib/types';
import { _ } from 'svelte-i18n';
interface Props {
fullWidth?: boolean;
}
let { fullWidth = false }: Props = $props();
// The colour-similarity threshold is per-browser view state (like the old
// client's table view state), so it lives in localStorage — not a server
// setting. Only meaningful for colour queries; the slider shows then.
const THRESHOLD_KEY = 'spoolman-search-threshold';
const DEFAULT_THRESHOLD = 20;
function loadThreshold(): number {
if (typeof localStorage === 'undefined') return DEFAULT_THRESHOLD;
const n = Number(localStorage.getItem(THRESHOLD_KEY));
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRESHOLD;
}
let query = $state('');
let threshold = $state(loadThreshold());
let results = $state<SearchResults | null>(null);
let loading = $state(false);
let errored = $state(false);
let open = $state(false);
let activeIndex = $state(-1);
let wrapper = $state<HTMLElement>();
// Extra-field definitions power the "matched: <field name>" badge for extra fields.
$effect(() => {
fields.ensure('spool');
fields.ensure('filament');
fields.ensure('vendor');
});
// Debounced, abortable search. Re-runs on query or threshold change (moving the
// slider re-queries so colour results update live).
let reqId = 0;
let controller: AbortController | undefined;
$effect(() => {
const q = query.trim();
const thr = threshold;
if (!q) {
results = null;
loading = false;
errored = false;
activeIndex = -1;
return;
}
loading = true;
const mine = ++reqId;
const timer = setTimeout(() => {
controller?.abort();
controller = new AbortController();
searchAll(q, thr, controller.signal)
.then((r) => {
if (mine !== reqId) return;
results = r;
loading = false;
errored = false;
activeIndex = -1;
})
.catch((e: unknown) => {
if (e instanceof DOMException && e.name === 'AbortError') return;
if (mine !== reqId) return;
console.error('Search failed', e);
errored = true;
loading = false;
});
}, 200);
return () => clearTimeout(timer);
});
// Close the panel when a click lands outside the box.
$effect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapper && !wrapper.contains(e.target as Node)) open = false;
};
window.addEventListener('pointerdown', onDown);
return () => window.removeEventListener('pointerdown', onDown);
});
interface FlatItem {
kind: EntityKind;
id: string;
}
let flat = $derived<FlatItem[]>(
results
? [
...results.spools.map((m) => ({ kind: 'spool' as const, id: String(m.entity.id) })),
...results.filaments.map((m) => ({ kind: 'filament' as const, id: m.entity.id })),
...results.vendors.map((m) => ({ kind: 'vendor' as const, id: m.entity.id }))
]
: []
);
let hasResults = $derived(flat.length > 0);
let showPanel = $derived(open && query.trim().length > 0);
function choose(kind: EntityKind, id: string) {
openSearchResult(kind, id);
open = false;
query = '';
results = null;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
open = true;
if (flat.length) activeIndex = Math.min(activeIndex + 1, flat.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && activeIndex < flat.length) {
e.preventDefault();
const item = flat[activeIndex];
choose(item.kind, item.id);
}
}
}
function persistThreshold() {
if (typeof localStorage !== 'undefined') localStorage.setItem(THRESHOLD_KEY, String(threshold));
}
// "matched: <field>" label. Extra fields resolve to the field's display name;
// native fields get a translated label (falling back to the raw name).
const NATIVE_MATCH = new Set([
'name',
'material',
'article_number',
'comment',
'location',
'lot_nr',
'id',
'color'
]);
function matchLabel(kind: EntityKind, matchField: string): string {
if (matchField.startsWith('extra.')) {
const key = matchField.slice(6);
return fields.get(kind).find((f) => f.key === key)?.name ?? key;
}
return NATIVE_MATCH.has(matchField) ? $_(`search.match.${matchField}`) : matchField;
}
// The flat index of an item, so hover/selection highlight lines up with keyboard nav.
function indexOf(kind: EntityKind, id: string): number {
return flat.findIndex((f) => f.kind === kind && f.id === id);
}
</script>
<div class="search-box" class:full={fullWidth} bind:this={wrapper}>
<SearchInput
value={query}
placeholder={'⌕ ' + $_('topbar.search_placeholder')}
oninput={(v) => {
query = v;
open = true;
}}
onfocus={() => (open = true)}
onkeydown={onKeydown}
{fullWidth}
/>
{#if showPanel}
<div class="panel" role="listbox" aria-label={$_('common.search')}>
{#if results?.isColorQuery}
<div class="color-controls">
<label class="slider">
<span>{$_('search.threshold')}</span>
<input type="range" min="0" max="60" step="1" bind:value={threshold} oninput={persistThreshold} />
<span class="val mono">{threshold}</span>
</label>
<p class="hint">{$_('search.color_hint')}</p>
</div>
{/if}
{#if loading && !results}
<div class="msg">{$_('search.searching')}</div>
{:else if errored}
<div class="msg">{$_('library.api_error')}</div>
{:else if !hasResults}
<div class="msg">{$_('search.no_results')}</div>
{:else}
{#if results && results.spools.length}
<div class="section-label">{$_('search.section.spools')}</div>
{#each results.spools as m (m.entity.id)}
{@const filament = inventory.filamentById(m.entity.filamentId)}
<button
class="result"
class:active={activeIndex === indexOf('spool', String(m.entity.id))}
onclick={() => choose('spool', String(m.entity.id))}
>
<span class="id mono">#{m.entity.id}</span>
<Swatch colors={filament?.colors ?? []} size={20} />
<span class="text">
<span class="title">{filament?.name || $_('search.unknown_filament')}</span>
{#if m.entity.location}<span class="sub">{m.entity.location}</span>{/if}
</span>
<span class="match">{matchLabel('spool', m.matchField)}</span>
</button>
{/each}
{/if}
{#if results && results.filaments.length}
<div class="section-label">{$_('search.section.filaments')}</div>
{#each results.filaments as m (m.entity.id)}
{@const vendor = inventory.vendorById(m.entity.vendorId)}
<button
class="result"
class:active={activeIndex === indexOf('filament', m.entity.id)}
onclick={() => choose('filament', m.entity.id)}
>
<Swatch colors={m.entity.colors} size={20} />
<span class="text">
<span class="title">{m.entity.name}</span>
{#if vendor?.name}<span class="sub">{vendor.name}</span>{/if}
</span>
{#if m.entity.material}<MaterialBadge label={m.entity.material} />{/if}
<span class="match">{matchLabel('filament', m.matchField)}</span>
</button>
{/each}
{/if}
{#if results && results.vendors.length}
<div class="section-label">{$_('search.section.vendors')}</div>
{#each results.vendors as m (m.entity.id)}
<button
class="result"
class:active={activeIndex === indexOf('vendor', m.entity.id)}
onclick={() => choose('vendor', m.entity.id)}
>
<span class="vendor-icon" aria-hidden="true">🏭</span>
<span class="text">
<span class="title">{m.entity.name}</span>
</span>
<span class="match">{matchLabel('vendor', m.matchField)}</span>
</button>
{/each}
{/if}
{/if}
</div>
{/if}
</div>
<style>
.search-box {
position: relative;
}
.search-box.full {
width: 100%;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 420px;
max-width: min(420px, calc(100vw - 28px));
max-height: min(70vh, 560px);
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
z-index: 40;
padding: 6px 0;
}
.search-box.full .panel {
width: 100%;
max-width: 100%;
left: 0;
right: 0;
}
.color-controls {
padding: 8px 12px 10px;
border-bottom: 1px solid var(--hairline);
}
.slider {
display: flex;
align-items: center;
gap: 10px;
font-size: 11.5px;
color: var(--text-dim);
}
.slider input[type='range'] {
flex: 1;
accent-color: var(--accent);
}
.slider .val {
width: 22px;
text-align: right;
color: var(--text);
}
.hint {
margin: 6px 0 0;
font-size: 10.5px;
color: var(--text-muted);
}
.section-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 8px 12px 4px;
}
.msg {
padding: 18px 12px;
text-align: center;
font-size: 12px;
color: var(--text-dim);
}
.result {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.result:hover,
.result.active {
background: var(--surface-2);
}
.id {
font-size: 11px;
color: var(--text-muted);
min-width: 30px;
flex: none;
}
.text {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
line-height: 1.25;
}
.title {
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
flex: none;
font-size: 10px;
color: var(--text-muted);
background: var(--surface-raised);
border-radius: 4px;
padding: 1px 6px;
white-space: nowrap;
}
.vendor-icon {
font-size: 15px;
width: 20px;
text-align: center;
flex: none;
}
</style>
+20 -7
View File
@@ -1,4 +1,5 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import type { EntityKind, Selection } from '$lib/types';
// The Library view's entire query state lives in the URL — this module is the
@@ -19,7 +20,6 @@ export interface FilterChip {
}
export interface LibraryState {
query: string;
selection: Selection | null;
group: GroupMode;
sortKey: string;
@@ -68,7 +68,6 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
const selection = kind && ENTITY_KINDS.includes(kind) ? { kind, id: sel!.slice(si + 1) } : null;
return {
query: params.get('q') ?? '',
selection,
group: group && GROUP_MODES.includes(group) ? group : DEFAULTS.group,
sortKey: params.get('sort') ?? DEFAULTS.sortKey,
@@ -91,7 +90,6 @@ export function isSelected(params: URLSearchParams, kind: EntityKind, id: string
/** Encode Library state back to a canonical query string (no leading `?`). */
function serializeState(s: LibraryState): string {
const p = new URLSearchParams();
if (s.query) p.set('q', s.query);
if (s.group !== DEFAULTS.group) p.set('group', s.group);
if (s.sortKey !== DEFAULTS.sortKey) p.set('sort', s.sortKey);
if (s.sortAsc !== DEFAULTS.sortAsc) p.set('dir', s.sortAsc ? 'asc' : 'desc');
@@ -128,10 +126,6 @@ function navigate(next: LibraryState, replace = false): void {
// --- mutators (each preserves the old ui-store semantics) ------------------
export function setQuery(query: string): void {
navigate({ ...currentState(), query, page: DEFAULTS.page }, true);
}
export function setGroup(group: GroupMode): void {
navigate({ ...currentState(), group, page: DEFAULTS.page });
}
@@ -173,3 +167,22 @@ export function select(kind: EntityKind, id: string): void {
export function clearSelection(): void {
navigate({ ...currentState(), selection: null });
}
/**
* Open a search result's inspector on the Library page. The search box lives in
* the layout, so a result can be picked from any route: when already on the
* Library page we merge the selection into the current view (preserving its
* group/sort/filters); otherwise we navigate to the Library root with just the
* selection.
*/
export function openSearchResult(kind: EntityKind, id: string): void {
const libraryPath = `${base}/`;
const path = window.location.pathname;
if (path === libraryPath || path === base || path === '/') {
select(kind, id);
return;
}
const p = new URLSearchParams();
p.set('sel', `${kind}:${id}`);
goto(`${libraryPath}?${p.toString()}`, { noScroll: true });
}
+22
View File
@@ -455,6 +455,28 @@
"add_spools": "Add spools",
"search_placeholder": "Search — try \"red\" or \"pla\""
},
"search": {
"searching": "Searching…",
"no_results": "No matches found",
"unknown_filament": "Unknown filament",
"threshold": "Color match",
"color_hint": "Color names must be entered in English.",
"section": {
"spools": "Spools",
"filaments": "Filaments",
"vendors": "Manufacturers"
},
"match": {
"name": "name",
"material": "material",
"article_number": "article number",
"comment": "comment",
"location": "location",
"lot_nr": "lot number",
"id": "ID",
"color": "color"
}
},
"footer": {
"documentation": "Documentation",
"report_issue": "Report an issue"
+51
View File
@@ -411,6 +411,57 @@ class SpoolGroup(BaseModel):
)
class SearchResultSpool(BaseModel):
"""A spool that matched a search, with which field matched."""
spool: Spool = Field(description="The matching spool.")
match_field: str = Field(
description=(
"Which field matched the query: a native field name (e.g. 'comment', 'location', "
"'lot_nr'), 'id' for an exact spool-id match, or 'extra.<key>' for an extra field."
),
examples=["comment"],
)
class SearchResultFilament(BaseModel):
"""A filament that matched a search, with which field matched."""
filament: Filament = Field(description="The matching filament.")
match_field: str = Field(
description=(
"Which field matched the query: a native field name (e.g. 'name', 'material', "
"'article_number', 'comment'), 'color' for a color-similarity match, or 'extra.<key>'."
),
examples=["color"],
)
class SearchResultVendor(BaseModel):
"""A vendor that matched a search, with which field matched."""
vendor: Vendor = Field(description="The matching vendor.")
match_field: str = Field(
description="Which field matched the query: 'name', 'comment', or 'extra.<key>'.",
examples=["name"],
)
class SearchResults(BaseModel):
"""Categorized results of a cross-entity search."""
spools: list[SearchResultSpool] = Field(description="Matching spools, best matches first.")
filaments: list[SearchResultFilament] = Field(description="Matching filaments, best matches first.")
vendors: list[SearchResultVendor] = Field(description="Matching vendors, best matches first.")
is_color_query: bool = Field(
description=(
"Whether the query was recognized as a color (hex code or CSS color name), in which case "
"the filament results include color-similarity matches and a threshold slider is relevant."
),
examples=[False],
)
class Info(BaseModel):
version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False])
+2 -1
View File
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import export, externaldb, field, filament, models, other, setting, spool, vendor
from . import export, externaldb, field, filament, models, other, search, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -112,3 +112,4 @@ app.include_router(field.router)
app.include_router(other.router)
app.include_router(externaldb.router)
app.include_router(export.router)
app.include_router(search.router)
+96
View File
@@ -0,0 +1,96 @@
"""Cross-entity search endpoint."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import (
Filament,
SearchResultFilament,
SearchResults,
SearchResultSpool,
SearchResultVendor,
Spool,
Vendor,
)
from spoolman.database import search
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/search",
tags=["search"],
)
# ruff: noqa: D103
@router.get(
"",
name="Search",
description=(
"Search across spools, filaments and vendors in a single request. The query is matched "
"case-insensitively against the text fields of each entity (and their text/choice extra "
"fields). A purely numeric query additionally matches a spool by its exact id. A query that "
"is a hex code (e.g. '#ff0000') or a CSS color name (e.g. 'red') additionally runs a "
"color-similarity search over filaments. Results are categorized by entity and each result "
"reports which field matched."
),
response_model_exclude_none=True,
)
async def search_endpoint(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
query: Annotated[
str,
Query(
alias="q",
title="Query",
description="The search query.",
min_length=1,
examples=["red"],
),
],
color_similarity_threshold: Annotated[
float,
Query(
title="Color Similarity Threshold",
description=(
"The similarity threshold for color matching, when the query is a color. "
"A value between 0.0-100.0, where 0 means match only exactly the same color."
),
ge=0.0,
le=100.0,
examples=[20.0],
),
] = 20.0,
limit: Annotated[
int,
Query(
title="Limit",
description="Maximum number of results per category (spools, filaments, vendors).",
ge=1,
le=100,
),
] = 20,
) -> SearchResults:
result = await search.search(
db=db,
query=query,
color_similarity_threshold=color_similarity_threshold,
limit=limit,
)
return SearchResults(
spools=[SearchResultSpool(spool=Spool.from_db(m.spool), match_field=m.match_field) for m in result.spools],
filaments=[
SearchResultFilament(filament=Filament.from_db(m.filament), match_field=m.match_field)
for m in result.filaments
],
vendors=[
SearchResultVendor(vendor=Vendor.from_db(m.vendor), match_field=m.match_field) for m in result.vendors
],
is_color_query=result.is_color_query,
)
+181
View File
@@ -0,0 +1,181 @@
"""CSS named colors and color-string resolution for the search feature.
Color names are the standard CSS Color Module Level 4 keywords, which are
English-only. ``resolve_color`` turns a user-supplied color string (a hex code or
one of these names) into a canonical ``RRGGBB`` hex string suitable for
``spoolman.math.hex_to_rgb``; it returns ``None`` when the string is not a color.
"""
import re
# CSS Color Module Level 4 named colors (148 keywords), lowercased name -> RRGGBB.
CSS_COLORS: dict[str, str] = {
"aliceblue": "f0f8ff",
"antiquewhite": "faebd7",
"aqua": "00ffff",
"aquamarine": "7fffd4",
"azure": "f0ffff",
"beige": "f5f5dc",
"bisque": "ffe4c4",
"black": "000000",
"blanchedalmond": "ffebcd",
"blue": "0000ff",
"blueviolet": "8a2be2",
"brown": "a52a2a",
"burlywood": "deb887",
"cadetblue": "5f9ea0",
"chartreuse": "7fff00",
"chocolate": "d2691e",
"coral": "ff7f50",
"cornflowerblue": "6495ed",
"cornsilk": "fff8dc",
"crimson": "dc143c",
"cyan": "00ffff",
"darkblue": "00008b",
"darkcyan": "008b8b",
"darkgoldenrod": "b8860b",
"darkgray": "a9a9a9",
"darkgreen": "006400",
"darkgrey": "a9a9a9",
"darkkhaki": "bdb76b",
"darkmagenta": "8b008b",
"darkolivegreen": "556b2f",
"darkorange": "ff8c00",
"darkorchid": "9932cc",
"darkred": "8b0000",
"darksalmon": "e9967a",
"darkseagreen": "8fbc8f",
"darkslateblue": "483d8b",
"darkslategray": "2f4f4f",
"darkslategrey": "2f4f4f",
"darkturquoise": "00ced1",
"darkviolet": "9400d3",
"deeppink": "ff1493",
"deepskyblue": "00bfff",
"dimgray": "696969",
"dimgrey": "696969",
"dodgerblue": "1e90ff",
"firebrick": "b22222",
"floralwhite": "fffaf0",
"forestgreen": "228b22",
"fuchsia": "ff00ff",
"gainsboro": "dcdcdc",
"ghostwhite": "f8f8ff",
"gold": "ffd700",
"goldenrod": "daa520",
"gray": "808080",
"green": "008000",
"greenyellow": "adff2f",
"grey": "808080",
"honeydew": "f0fff0",
"hotpink": "ff69b4",
"indianred": "cd5c5c",
"indigo": "4b0082",
"ivory": "fffff0",
"khaki": "f0e68c",
"lavender": "e6e6fa",
"lavenderblush": "fff0f5",
"lawngreen": "7cfc00",
"lemonchiffon": "fffacd",
"lightblue": "add8e6",
"lightcoral": "f08080",
"lightcyan": "e0ffff",
"lightgoldenrodyellow": "fafad2",
"lightgray": "d3d3d3",
"lightgreen": "90ee90",
"lightgrey": "d3d3d3",
"lightpink": "ffb6c1",
"lightsalmon": "ffa07a",
"lightseagreen": "20b2aa",
"lightskyblue": "87cefa",
"lightslategray": "778899",
"lightslategrey": "778899",
"lightsteelblue": "b0c4de",
"lightyellow": "ffffe0",
"lime": "00ff00",
"limegreen": "32cd32",
"linen": "faf0e6",
"magenta": "ff00ff",
"maroon": "800000",
"mediumaquamarine": "66cdaa",
"mediumblue": "0000cd",
"mediumorchid": "ba55d3",
"mediumpurple": "9370db",
"mediumseagreen": "3cb371",
"mediumslateblue": "7b68ee",
"mediumspringgreen": "00fa9a",
"mediumturquoise": "48d1cc",
"mediumvioletred": "c71585",
"midnightblue": "191970",
"mintcream": "f5fffa",
"mistyrose": "ffe4e1",
"moccasin": "ffe4b5",
"navajowhite": "ffdead",
"navy": "000080",
"oldlace": "fdf5e6",
"olive": "808000",
"olivedrab": "6b8e23",
"orange": "ffa500",
"orangered": "ff4500",
"orchid": "da70d6",
"palegoldenrod": "eee8aa",
"palegreen": "98fb98",
"paleturquoise": "afeeee",
"palevioletred": "db7093",
"papayawhip": "ffefd5",
"peachpuff": "ffdab9",
"peru": "cd853f",
"pink": "ffc0cb",
"plum": "dda0dd",
"powderblue": "b0e0e6",
"purple": "800080",
"rebeccapurple": "663399",
"red": "ff0000",
"rosybrown": "bc8f8f",
"royalblue": "4169e1",
"saddlebrown": "8b4513",
"salmon": "fa8072",
"sandybrown": "f4a460",
"seagreen": "2e8b57",
"seashell": "fff5ee",
"sienna": "a0522d",
"silver": "c0c0c0",
"skyblue": "87ceeb",
"slateblue": "6a5acd",
"slategray": "708090",
"slategrey": "708090",
"snow": "fffafa",
"springgreen": "00ff7f",
"steelblue": "4682b4",
"tan": "d2b48c",
"teal": "008080",
"thistle": "d8bfd8",
"tomato": "ff6347",
"turquoise": "40e0d0",
"violet": "ee82ee",
"wheat": "f5deb3",
"white": "ffffff",
"whitesmoke": "f5f5f5",
"yellow": "ffff00",
"yellowgreen": "9acd32",
}
_HEX_RE = re.compile(r"^#?([0-9a-f]{3}|[0-9a-f]{6})$")
def resolve_color(query: str) -> str | None:
"""Resolve a color string to a canonical ``RRGGBB`` hex, or ``None``.
Accepts a hex code (``#rgb``, ``#rrggbb`` or the same without ``#``) or a CSS
color name (case-insensitive, surrounding/internal whitespace ignored).
"""
normalized = query.strip().lower()
match = _HEX_RE.match(normalized.replace(" ", ""))
if match is not None:
hex_digits = match.group(1)
if len(hex_digits) == 3: # noqa: PLR2004 - expand shorthand #rgb -> #rrggbb
hex_digits = "".join(ch * 2 for ch in hex_digits)
return hex_digits
return CSS_COLORS.get(normalized.replace(" ", ""))
+321
View File
@@ -0,0 +1,321 @@
"""Cross-entity free-text / id / color search backing the /search endpoint.
Searches spools, filaments and vendors in one call and reports, per result, which
field matched. Text matching uses case-insensitive ``ilike`` so it works on all four
supported databases. A purely numeric query also matches a spool by its id, and a
query that is a hex code or a CSS color name runs a color-similarity search over
filaments (reusing :func:`spoolman.database.filament.find_by_color`).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from sqlalchemy import or_, select
from sqlalchemy.orm import joinedload
from spoolman.colors import resolve_color
from spoolman.database import filament as filament_db
from spoolman.database import models
from spoolman.extra_field_registry import EntityType, ExtraFieldType, get_extra_fields
from spoolman.math import delta_e, hex_to_rgb, rgb_to_lab
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
# How many candidate rows to pull per entity before ranking and trimming to `limit`.
# The search box only needs the top handful; this bound keeps ranking cheap while
# still letting prefix matches win over substring ones within a generous window.
_CANDIDATE_CAP = 200
# Extra-field types whose stored value is human-readable text worth searching.
_TEXT_EXTRA_TYPES = (ExtraFieldType.text, ExtraFieldType.choice)
# Rank buckets (lower sorts first).
_RANK_ID_OR_COLOR = -1 # exact id / color-similarity match
_RANK_STRONG = 0 # native field prefix/exact text match
_RANK_WEAK = 1 # native field substring text match
_RANK_EXTRA = 2 # extra-field text match
@dataclass
class SpoolMatch:
spool: models.Spool
match_field: str
@dataclass
class FilamentMatch:
filament: models.Filament
match_field: str
@dataclass
class VendorMatch:
vendor: models.Vendor
match_field: str
@dataclass
class SearchResult:
spools: list[SpoolMatch]
filaments: list[FilamentMatch]
vendors: list[VendorMatch]
is_color_query: bool
def _classify_match(q_lower: str, fields: list[tuple[str, str | None]]) -> tuple[str, int] | None:
"""Given ordered ``(label, value)`` pairs, return ``(label, rank)`` for the best match.
A prefix/exact hit outranks a mere substring hit. Returns ``None`` if nothing matched
(shouldn't happen for rows the SQL ``ilike`` already selected, but stays defensive).
"""
weak: tuple[str, int] | None = None
for label, value in fields:
if not value:
continue
v = value.lower()
if q_lower in v:
if v == q_lower or v.startswith(q_lower):
return label, _RANK_STRONG
if weak is None:
weak = (label, _RANK_WEAK)
return weak
async def _text_extra_keys(db: AsyncSession, entity_type: EntityType) -> list[str]:
"""Keys of the entity's text/choice extra fields (the ones worth text-searching)."""
fields = await get_extra_fields(db, entity_type)
return [f.key for f in fields if f.field_type in _TEXT_EXTRA_TYPES]
async def _search_spools(db: AsyncSession, q: str, limit: int) -> list[SpoolMatch]:
q_lower = q.lower()
# id -> (match_field, rank); spool object filled in afterwards.
hits: dict[int, tuple[str, int]] = {}
spools: dict[int, models.Spool] = {}
load = (joinedload(models.Spool.filament).joinedload(models.Filament.vendor),)
native_fields = (
("comment", models.Spool.comment),
("location", models.Spool.location),
("lot_nr", models.Spool.lot_nr),
)
stmt = (
select(models.Spool)
.where(or_(*(col.ilike(f"%{q}%") for _, col in native_fields)))
.options(*load)
.order_by(models.Spool.id)
.limit(_CANDIDATE_CAP)
)
rows = (await db.execute(stmt)).unique().scalars().all()
for spool in rows:
classified = _classify_match(
q_lower,
[(label, getattr(spool, attr.key)) for label, attr in native_fields],
)
if classified is None:
continue
spools[spool.id] = spool
hits[spool.id] = classified
# Extra-field (text/choice) matches.
keys = await _text_extra_keys(db, EntityType.spool)
if keys:
estmt = (
select(models.SpoolField.spool_id, models.SpoolField.key)
.where(models.SpoolField.key.in_(keys))
.where(models.SpoolField.value.ilike(f"%{q}%"))
.limit(_CANDIDATE_CAP)
)
need: list[int] = []
for spool_id, key in (await db.execute(estmt)).all():
if spool_id not in hits:
hits[spool_id] = (f"extra.{key}", _RANK_EXTRA)
need.append(spool_id)
if need:
fetch = select(models.Spool).where(models.Spool.id.in_(need)).options(*load)
for spool in (await db.execute(fetch)).unique().scalars().all():
spools[spool.id] = spool
ordered = sorted(hits.items(), key=lambda kv: (kv[1][1], kv[0]))[:limit]
return [SpoolMatch(spool=spools[sid], match_field=field) for sid, (field, _rank) in ordered]
async def _search_filaments(
db: AsyncSession,
q: str,
limit: int,
color_hits: list[FilamentMatch],
) -> list[FilamentMatch]:
q_lower = q.lower()
hits: dict[int, tuple[str, int]] = {}
filaments: dict[int, models.Filament] = {}
# Color matches (already ordered by closeness) claim the top rank; their relative
# order is preserved below via `color_order`.
for match in color_hits:
fid = match.filament.id
filaments[fid] = match.filament
hits[fid] = (match.match_field, _RANK_ID_OR_COLOR)
load = (joinedload(models.Filament.vendor),)
native_fields = (
("name", models.Filament.name),
("material", models.Filament.material),
("article_number", models.Filament.article_number),
("comment", models.Filament.comment),
)
stmt = (
select(models.Filament)
.where(or_(*(col.ilike(f"%{q}%") for _, col in native_fields)))
.options(*load)
.order_by(models.Filament.id)
.limit(_CANDIDATE_CAP)
)
for filament in (await db.execute(stmt)).unique().scalars().all():
if filament.id in hits:
continue # color match already annotated this one
classified = _classify_match(
q_lower,
[(label, getattr(filament, attr.key)) for label, attr in native_fields],
)
if classified is None:
continue
filaments[filament.id] = filament
hits[filament.id] = classified
keys = await _text_extra_keys(db, EntityType.filament)
if keys:
estmt = (
select(models.FilamentField.filament_id, models.FilamentField.key)
.where(models.FilamentField.key.in_(keys))
.where(models.FilamentField.value.ilike(f"%{q}%"))
.limit(_CANDIDATE_CAP)
)
need: list[int] = []
for filament_id, key in (await db.execute(estmt)).all():
if filament_id not in hits:
hits[filament_id] = (f"extra.{key}", _RANK_EXTRA)
need.append(filament_id)
if need:
fetch = select(models.Filament).where(models.Filament.id.in_(need)).options(*load)
for filament in (await db.execute(fetch)).unique().scalars().all():
filaments[filament.id] = filament
# Color hits keep their supplied order; everyone else sorts by (rank, id).
color_order = {m.filament.id: i for i, m in enumerate(color_hits)}
ordered = sorted(
hits.items(),
key=lambda kv: (kv[1][1], color_order.get(kv[0], 0), kv[0]),
)[:limit]
return [FilamentMatch(filament=filaments[fid], match_field=field) for fid, (field, _rank) in ordered]
async def _search_vendors(db: AsyncSession, q: str, limit: int) -> list[VendorMatch]:
q_lower = q.lower()
hits: dict[int, tuple[str, int]] = {}
vendors: dict[int, models.Vendor] = {}
native_fields = (
("name", models.Vendor.name),
("comment", models.Vendor.comment),
)
stmt = (
select(models.Vendor)
.where(or_(*(col.ilike(f"%{q}%") for _, col in native_fields)))
.order_by(models.Vendor.id)
.limit(_CANDIDATE_CAP)
)
for vendor in (await db.execute(stmt)).unique().scalars().all():
classified = _classify_match(
q_lower,
[(label, getattr(vendor, attr.key)) for label, attr in native_fields],
)
if classified is None:
continue
vendors[vendor.id] = vendor
hits[vendor.id] = classified
keys = await _text_extra_keys(db, EntityType.vendor)
if keys:
estmt = (
select(models.VendorField.vendor_id, models.VendorField.key)
.where(models.VendorField.key.in_(keys))
.where(models.VendorField.value.ilike(f"%{q}%"))
.limit(_CANDIDATE_CAP)
)
need: list[int] = []
for vendor_id, key in (await db.execute(estmt)).all():
if vendor_id not in hits:
hits[vendor_id] = (f"extra.{key}", _RANK_EXTRA)
need.append(vendor_id)
if need:
fetch = select(models.Vendor).where(models.Vendor.id.in_(need))
for vendor in (await db.execute(fetch)).unique().scalars().all():
vendors[vendor.id] = vendor
ordered = sorted(hits.items(), key=lambda kv: (kv[1][1], kv[0]))[:limit]
return [VendorMatch(vendor=vendors[vid], match_field=field) for vid, (field, _rank) in ordered]
def _filament_color_distance(filament: models.Filament, query_lab: list[float]) -> float:
"""Smallest CIELAB delta-E between the query color and any of the filament's colors."""
if filament.color_hex is not None:
colors = [filament.color_hex]
elif filament.multi_color_hexes is not None:
colors = filament.multi_color_hexes.split(",")
else:
return float("inf")
return min(delta_e(query_lab, rgb_to_lab(hex_to_rgb(color))) for color in colors)
async def _color_matches(db: AsyncSession, color_hex: str, threshold: float, limit: int) -> list[FilamentMatch]:
"""Color-similar filaments, closest first."""
matched = await filament_db.find_by_color(
db=db,
color_query_hex=color_hex,
similarity_threshold=threshold,
)
query_lab = rgb_to_lab(hex_to_rgb(color_hex))
matched.sort(key=lambda f: _filament_color_distance(f, query_lab))
return [FilamentMatch(filament=f, match_field="color") for f in matched[:limit]]
async def search(
*,
db: AsyncSession,
query: str,
color_similarity_threshold: float = 20.0,
limit: int = 20,
) -> SearchResult:
"""Run a cross-entity search for ``query`` and return categorized, annotated results."""
q = query.strip()
if not q:
return SearchResult(spools=[], filaments=[], vendors=[], is_color_query=False)
color_hex = resolve_color(q)
is_color_query = color_hex is not None
color_hits = await _color_matches(db, color_hex, color_similarity_threshold, limit) if color_hex else []
spools = await _search_spools(db, q, limit)
filaments = await _search_filaments(db, q, limit, color_hits)
vendors = await _search_vendors(db, q, limit)
# Numeric query: surface the spool with that exact id at the very top.
if q.isdigit():
spool_id = int(q)
if not any(m.spool.id == spool_id for m in spools):
spool = await db.get(
models.Spool,
spool_id,
options=[joinedload(models.Spool.filament).joinedload(models.Filament.vendor)],
)
if spool is not None:
spools.insert(0, SpoolMatch(spool=spool, match_field="id"))
spools = spools[:limit]
return SearchResult(spools=spools, filaments=filaments, vendors=vendors, is_color_query=is_color_query)
@@ -0,0 +1 @@
"""Integration tests for the search endpoint."""
@@ -0,0 +1,166 @@
"""Integration tests for the /search endpoint."""
import json
import uuid
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import httpx
import pytest
from ..conftest import URL, assert_httpx_success
# Unique per test run so our queries can't collide with data from other tests.
SFX = uuid.uuid4().hex[:8]
VENDOR_NAME = f"SearchVendor{SFX}"
FILAMENT_NAME = f"SearchFilament{SFX}"
FILAMENT_COMMENT = f"fcomment{SFX}"
SPOOL_LOCATION = f"SearchLoc{SFX}"
SPOOL_COMMENT = f"scomment{SFX}"
TEXT_VALUE = f"TextVal{SFX}"
CHOICE_VALUE = f"ChoiceVal{SFX}"
@dataclass
class Fixture:
vendor: dict[str, Any]
filament: dict[str, Any]
spool: dict[str, Any]
@pytest.fixture(scope="module")
def data() -> Iterable[Fixture]:
"""Seed one vendor + one red filament + one spool with text/choice extra fields."""
# Extra field definitions on the spool.
assert_httpx_success(
httpx.post(
f"{URL}/api/v1/field/spool/searchtext",
json={"name": "Search Text", "field_type": "text"},
),
)
assert_httpx_success(
httpx.post(
f"{URL}/api/v1/field/spool/searchchoice",
json={"name": "Search Choice", "field_type": "choice", "choices": [CHOICE_VALUE, "other"]},
),
)
result = httpx.post(f"{URL}/api/v1/vendor", json={"name": VENDOR_NAME})
result.raise_for_status()
vendor = result.json()
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"name": FILAMENT_NAME,
"vendor_id": vendor["id"],
"material": "PLA",
"density": 1.25,
"diameter": 1.75,
"color_hex": "ff0000",
"comment": FILAMENT_COMMENT,
},
)
result.raise_for_status()
filament = result.json()
result = httpx.post(
f"{URL}/api/v1/spool",
json={
"filament_id": filament["id"],
"remaining_weight": 1000,
"location": SPOOL_LOCATION,
"comment": SPOOL_COMMENT,
"extra": {
"searchtext": json.dumps(TEXT_VALUE),
"searchchoice": json.dumps(CHOICE_VALUE),
},
},
)
result.raise_for_status()
spool = result.json()
yield Fixture(vendor=vendor, filament=filament, spool=spool)
httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status()
httpx.delete(f"{URL}/api/v1/field/spool/searchtext").raise_for_status()
httpx.delete(f"{URL}/api/v1/field/spool/searchchoice").raise_for_status()
def _search(query: str, color_similarity_threshold: float | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"q": query}
if color_similarity_threshold is not None:
params["color_similarity_threshold"] = color_similarity_threshold
result = httpx.get(f"{URL}/api/v1/search", params=params)
result.raise_for_status()
return result.json()
def _has(items: list[dict[str, Any]], entity_key: str, entity_id: int, match_field: str) -> bool:
return any(i[entity_key]["id"] == entity_id and i["match_field"] == match_field for i in items)
def test_search_filament_name(data: Fixture):
body = _search(FILAMENT_NAME)
assert body["is_color_query"] is False
assert _has(body["filaments"], "filament", data.filament["id"], "name")
def test_search_vendor_name(data: Fixture):
body = _search(VENDOR_NAME)
assert _has(body["vendors"], "vendor", data.vendor["id"], "name")
def test_search_filament_comment(data: Fixture):
body = _search(FILAMENT_COMMENT)
assert _has(body["filaments"], "filament", data.filament["id"], "comment")
def test_search_spool_location(data: Fixture):
body = _search(SPOOL_LOCATION)
assert _has(body["spools"], "spool", data.spool["id"], "location")
def test_search_spool_comment(data: Fixture):
body = _search(SPOOL_COMMENT)
assert _has(body["spools"], "spool", data.spool["id"], "comment")
def test_search_extra_text_field(data: Fixture):
body = _search(TEXT_VALUE)
assert _has(body["spools"], "spool", data.spool["id"], "extra.searchtext")
def test_search_extra_choice_field(data: Fixture):
body = _search(CHOICE_VALUE)
assert _has(body["spools"], "spool", data.spool["id"], "extra.searchchoice")
def test_search_spool_by_id(data: Fixture):
body = _search(str(data.spool["id"]))
assert _has(body["spools"], "spool", data.spool["id"], "id")
def test_search_color_name(data: Fixture):
body = _search("red", color_similarity_threshold=20.0)
assert body["is_color_query"] is True
assert _has(body["filaments"], "filament", data.filament["id"], "color")
def test_search_color_hex(data: Fixture):
body = _search("#ff0000", color_similarity_threshold=5.0)
assert body["is_color_query"] is True
assert _has(body["filaments"], "filament", data.filament["id"], "color")
def test_search_no_match(data: Fixture):
assert data.spool["id"] # ensure the fixture data exists so "no match" is meaningful
body = _search(f"zzznomatch{SFX}")
assert body["is_color_query"] is False
assert body["spools"] == []
assert body["filaments"] == []
assert body["vendors"] == []