feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.
- Nine new filter params on SearchMediaItemsUnified: story_arc,
imprint, edition (scalar-fuzzy), age_rating, format_group,
manga_type, reading_direction (exact), explicit, abridged
(presence-bound bools). Comma = OR-within multi-select on every
text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
> empty (unknown-only stays tags-only). Type-words (Manga, Comics,
Graphic Novel) join the demote list. Both 'adventure' and 'action &
adventure' are shelves — provider data uses both and the verified
traces pin them.
- Language filter UX: comma elements normalize through the ingest
language map, so display names ('English') match rows stored as ISO
codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
driven value+count listing (14 static per-column queries, family-lens
join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
403 on the search endpoints (was a silent empty set) — JSON branch
and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
search JSON endpoint and the HTMX grid partial; the HTML branch
previously dropped publisher_filter and narrator_filter silently.
Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
This commit is contained in:
1 parent
7537063cb7
commit
7e586df48a
13 files changed
+2297
-283
No files matched your search
@@ -26,10 +26,10 @@ func TestSplitSeriesString(t *testing.T) {
|
||||
|
||||
func TestSeriesSequenceNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
num float64
|
||||
found bool
|
||||
lossy bool
|
||||
in string
|
||||
num float64
|
||||
found bool
|
||||
lossy bool
|
||||
}{
|
||||
{"3", 3, true, false},
|
||||
{"1.5", 1.5, true, false},
|
||||
@@ -181,12 +181,34 @@ func TestPickSmartGenre(t *testing.T) {
|
||||
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
||||
t.Errorf("audiobook is not a genre, got %q", genre)
|
||||
}
|
||||
// Fiction is the last resort, not the winner.
|
||||
// Fiction is the floor: alone it resolves, against a real shelf it loses.
|
||||
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
|
||||
t.Errorf("plain Fiction should still resolve, got %q", genre)
|
||||
}
|
||||
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "epic fantasy" {
|
||||
t.Errorf("specificity must beat generic, got %q", genre)
|
||||
// Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets
|
||||
// its parent shelf and the specific label lives in tags. (Intended
|
||||
// change from the old longest-match behavior, which returned
|
||||
// "epic fantasy" here.)
|
||||
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" {
|
||||
t.Errorf("subgenre must resolve to its shelf, got %q", genre)
|
||||
}
|
||||
// User-verified traces (Batch B decision):
|
||||
if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" {
|
||||
t.Errorf("direct shelf beats mapped subgenre, got %q", genre)
|
||||
}
|
||||
if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" {
|
||||
t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre)
|
||||
}
|
||||
if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" {
|
||||
t.Errorf("direct shelf beats floor, got %q", genre)
|
||||
}
|
||||
// Type-words describe the library type, not a shelf.
|
||||
if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" {
|
||||
t.Errorf("manga is a type-word, got %q", genre)
|
||||
}
|
||||
// Unknown-only input: no shelf is known — tags carry it, never guess.
|
||||
if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" {
|
||||
t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user