Two follow-ups from live testing: - "recent" is BOTH a base toolbar key and a full registry key (where it means desc). ResolveSeriesSort's full-key passthrough ran first, so sort=recent&dir=asc silently resolved back to desc — the direction chevron flipped but Date Added could never sort ascending. "recent" now composes with the direction before the passthrough, unknown bases fall to the default entirely (a hostile dir can't ride an unknown sort), and the composition table is unit-pinned plus an HTTP-level row in the series filter test. - The header search "still broken" report was a stale-cache artifact: the fixed code shipped but main.js kept its ?v=7 stamp, so browsers with a cached copy never refetched (fresh-profile e2e passed while the user's browser didn't — heuristically cached module). Stamp bumped to ?v=8 in header + docs; the e2e suite re-verified against the rebuilt server.
360 lines
13 KiB
Go
360 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func TestSplitSeriesString(t *testing.T) {
|
|
cases := []struct{ in, name, seq string }{
|
|
{"The Witcher #3", "The Witcher", "3"},
|
|
{"Name #1a", "Name", "1a"},
|
|
{"No Sequence", "No Sequence", ""},
|
|
{"Name #1#a", "Name #1#a", ""}, // embedded # is not a sequence
|
|
}
|
|
for _, c := range cases {
|
|
name, seq := splitSeriesString(c.in)
|
|
if name != c.name || seq != c.seq {
|
|
t.Errorf("splitSeriesString(%q) = (%q, %q), want (%q, %q)", c.in, name, seq, c.name, c.seq)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSeriesSequenceNumber(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
num float64
|
|
found bool
|
|
lossy bool
|
|
}{
|
|
{"3", 3, true, false},
|
|
{"1.5", 1.5, true, false},
|
|
{"1a", 1, true, true},
|
|
{"Book 2", 2, true, true},
|
|
{"2, Dramatized Adaptation", 2, true, true},
|
|
{"", 0, false, false},
|
|
{"novella", 0, false, false},
|
|
}
|
|
for _, c := range cases {
|
|
num, found, lossy := seriesSequenceNumber(c.in)
|
|
if num != c.num || found != c.found || lossy != c.lossy {
|
|
t.Errorf("seriesSequenceNumber(%q) = (%v,%v,%v), want (%v,%v,%v)",
|
|
c.in, num, found, lossy, c.num, c.found, c.lossy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The A2c headline bug: ABS abmetadata writes series as an array of
|
|
// "Name #seq" STRINGS. The old parser only accepted object arrays and
|
|
// silently dropped every ABS-matched series.
|
|
func TestParseSeriesCandidatesStringArray(t *testing.T) {
|
|
raw := []byte(`["The Witcher #3", "The Witcher (Reading Order) #5"]`)
|
|
candidates := parseSeriesCandidates(raw)
|
|
if len(candidates) != 2 {
|
|
t.Fatalf("got %d candidates, want 2", len(candidates))
|
|
}
|
|
if candidates[0].Name != "The Witcher" || candidates[0].Number != 3 || !candidates[0].HasNum {
|
|
t.Errorf("candidate[0] = %+v", candidates[0])
|
|
}
|
|
if candidates[1].Name != "The Witcher (Reading Order)" || candidates[1].Number != 5 {
|
|
t.Errorf("candidate[1] = %+v", candidates[1])
|
|
}
|
|
if !isPlaylistSeriesName(candidates[1].Name) {
|
|
t.Errorf("reading-order entry must classify as playlist-ish")
|
|
}
|
|
if isPlaylistSeriesName(candidates[0].Name) {
|
|
t.Errorf("publication series must not classify as playlist-ish")
|
|
}
|
|
}
|
|
|
|
func TestParseSeriesCandidatesOtherShapes(t *testing.T) {
|
|
objs := parseSeriesCandidates([]byte(`[{"series":"Great Series","sequence":"2.5"}]`))
|
|
if len(objs) != 1 || objs[0].Name != "Great Series" || objs[0].Number != 2.5 {
|
|
t.Errorf("object form: %+v", objs)
|
|
}
|
|
libation := parseSeriesCandidates([]byte(`"Libation Series #4"`))
|
|
if len(libation) != 1 || libation[0].Name != "Libation Series" || libation[0].Number != 4 {
|
|
t.Errorf("plain string form: %+v", libation)
|
|
}
|
|
if parseSeriesCandidates([]byte(`null`)) != nil {
|
|
t.Errorf("null must yield no candidates")
|
|
}
|
|
}
|
|
|
|
func TestIsPlaylistSeriesName(t *testing.T) {
|
|
positives := []string{
|
|
"The Witcher (Reading Order)",
|
|
"Discworld Reading Order",
|
|
"Narnia Chronological Order",
|
|
"Robin Hobb in order",
|
|
"Series Publication Order",
|
|
"Middle Earth Timeline",
|
|
}
|
|
for _, p := range positives {
|
|
if !isPlaylistSeriesName(p) {
|
|
t.Errorf("%q should be playlist-ish", p)
|
|
}
|
|
}
|
|
negatives := []string{"The Witcher", "Witcher Saga", "The Timeline of..."}
|
|
for _, n := range negatives {
|
|
if isPlaylistSeriesName(n) {
|
|
t.Errorf("%q must not be playlist-ish", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeriveSeriesFromText(t *testing.T) {
|
|
name, num, hasNum, ok := deriveSeriesFromText("The Witcher, Book 3")
|
|
if !ok || name != "The Witcher" || num != 3 || !hasNum {
|
|
t.Errorf("got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
|
}
|
|
name, num, hasNum, ok = deriveSeriesFromText("The Witcher, Book 1.5")
|
|
if !ok || name != "The Witcher" || num != 1.5 || !hasNum {
|
|
t.Errorf("decimal: got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
|
}
|
|
// Not obviously a series tail → must refuse rather than guess.
|
|
for _, negative := range []string{"1984", "The Fellowship", "Book Ends"} {
|
|
if _, _, _, ok := deriveSeriesFromText(negative); ok {
|
|
t.Errorf("%q must not derive a series", negative)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitTitleOnSubtitle(t *testing.T) {
|
|
title, subtitle, split := splitTitleOnSubtitle("Baptism of Fire: The Witcher, Book 3")
|
|
if !split || title != "Baptism of Fire" || subtitle != "The Witcher, Book 3" {
|
|
t.Errorf("got (%q,%q,%v)", title, subtitle, split)
|
|
}
|
|
// Arbitrary colons never split.
|
|
_, _, split = splitTitleOnSubtitle("Star Wars: A New Hope")
|
|
if split {
|
|
t.Errorf("non-series colon must not split")
|
|
}
|
|
}
|
|
|
|
func TestApplyTitleDerivedSeries(t *testing.T) {
|
|
// The user's actual naming, two-level folder, no sidecar anywhere.
|
|
meta := &MediaMetadata{Title: "Baptism of Fire: The Witcher, Book 3"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 3 {
|
|
t.Errorf("series = %q #%v, want The Witcher #3", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if meta.Title != "Baptism of Fire: The Witcher, Book 3" {
|
|
t.Errorf("fallback must never mutate the display title, got %q", meta.Title)
|
|
}
|
|
|
|
// Subtitle already split by the folder parser: number + name both land.
|
|
meta = &MediaMetadata{Title: "Blood of Elves", Subtitle: "The Witcher, Book 1"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v, want The Witcher #1", meta.Series, meta.SeriesNumber)
|
|
}
|
|
|
|
// Existing series data is never overwritten (blanks-only fallback)...
|
|
meta = &MediaMetadata{Title: "X", Subtitle: "Derived, Book 9", Series: "Real Series"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "Real Series" || meta.SeriesNumber != 0 {
|
|
t.Errorf("a number from a different series name must not apply, got %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
// ...but a number whose derived name MATCHES the existing series fills it.
|
|
meta = &MediaMetadata{Title: "X", Subtitle: "Real Series, Book 9", Series: "Real Series"}
|
|
applyTitleDerivedSeries(meta)
|
|
if meta.Series != "Real Series" || meta.SeriesNumber != 9 {
|
|
t.Errorf("matching-name number must fill, got %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
}
|
|
|
|
func TestPickSmartGenre(t *testing.T) {
|
|
// The real Malazan Audible blob: one string, four genres.
|
|
genre, all := pickSmartGenre([]string{"Epic, Action & Adventure, Military, Sword & Sorcery"})
|
|
if genre != "action & adventure" {
|
|
t.Errorf("genre = %q, want action & adventure", genre)
|
|
}
|
|
if len(all) != 4 {
|
|
t.Errorf("candidates = %v, want the 4 split parts", all)
|
|
}
|
|
// Format noise never wins.
|
|
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
|
t.Errorf("audiobook is not a genre, got %q", genre)
|
|
}
|
|
// 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)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLanguageCode(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"English", "en"},
|
|
{"en", "en"},
|
|
{"German", "de"},
|
|
{"", ""},
|
|
{"Norwegian (Bokmål)", ""}, // too long for VARCHAR(10); dropped, never truncated
|
|
{"Pig Latin", "Pig Latin"}, // unknown display name that fits: kept
|
|
}
|
|
for _, c := range cases {
|
|
if got := normalizeLanguageCode(c.in); got != c.want {
|
|
t.Errorf("normalizeLanguageCode(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// End-to-end: a REAL ABS-shaped sidecar (string-array series, the shape
|
|
// every ABS-matched book writes) must land series, language, explicit and
|
|
// the full author array.
|
|
func TestExtractAudiobookshelfSidecarStringArraySeries(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sidecar := `{
|
|
"title": "Blood of Elves",
|
|
"authors": ["Andrzej Sapkowski"],
|
|
"narrators": ["Peter Kenny"],
|
|
"series": ["The Witcher #1", "The Witcher (Reading Order) #3"],
|
|
"genres": ["Fiction"],
|
|
"language": "English",
|
|
"explicit": true,
|
|
"publisher": "Orbit"
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
|
if meta == nil {
|
|
t.Fatal("sidecar not extracted")
|
|
}
|
|
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if len(meta.Authors) != 1 || meta.Authors[0] != "Andrzej Sapkowski" {
|
|
t.Errorf("authors array = %v", meta.Authors)
|
|
}
|
|
if meta.Language != "en" {
|
|
t.Errorf("language = %q, want en", meta.Language)
|
|
}
|
|
if !meta.Explicit {
|
|
t.Errorf("explicit flag lost")
|
|
}
|
|
if len(meta.PlaylistSeries) != 1 || meta.PlaylistSeries[0].Name != "The Witcher (Reading Order)" {
|
|
t.Errorf("playlist series = %+v", meta.PlaylistSeries)
|
|
}
|
|
if meta.AlternateInfo != "" {
|
|
t.Errorf("playlist entry must not leak into alternate_info, got %q", meta.AlternateInfo)
|
|
}
|
|
}
|
|
|
|
// Additional real series (the Drizzt case: Icewind Dale #1 = Legend of
|
|
// Drizzt #4) land in alternate_info with the comic-established shape.
|
|
func TestExtractAudiobookshelfSidecarExtraSeriesToAlternateInfo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sidecar := `{
|
|
"title": "The Crystal Shard",
|
|
"authors": ["R.A. Salvatore"],
|
|
"series": [
|
|
{"series": "The Icewind Dale Trilogy", "sequence": "1"},
|
|
{"series": "The Legend of Drizzt", "sequence": "4"}
|
|
]
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
|
if meta.Series != "The Icewind Dale Trilogy" || meta.SeriesNumber != 1 {
|
|
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
|
}
|
|
if meta.AlternateInfo == "" {
|
|
t.Fatal("second series must land in alternate_info")
|
|
}
|
|
// Lossy sequence text is preserved, never silently dropped.
|
|
meta2 := extractAudiobookshelfSidecarPath(t, dir, `{"title":"X","series":["Saga #1a"]}`)
|
|
if meta2 == nil || meta2.Series != "Saga" || meta2.SeriesNumber != 1 {
|
|
t.Fatalf("lossy series = %+v", meta2)
|
|
}
|
|
if meta2.AlternateInfo == "" || !strings.Contains(meta2.AlternateInfo, "series_sequence_raw") || !strings.Contains(meta2.AlternateInfo, "1a") {
|
|
t.Errorf("raw sequence not preserved: %q", meta2.AlternateInfo)
|
|
}
|
|
}
|
|
|
|
func extractAudiobookshelfSidecarPath(t *testing.T, dir, json string) *MediaMetadata {
|
|
t.Helper()
|
|
// extractAudiobookshelfSidecar looks for exactly "metadata.json"; use a
|
|
// fresh dir per call instead.
|
|
fresh := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(fresh, "metadata.json"), []byte(json), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return extractAudiobookshelfSidecar(filepath.Join(fresh, "book.m4b"))
|
|
}
|
|
|
|
// The series page tiebreaker rides on the same ordering contract the
|
|
// scanner feeds: numbers sort numerically, NULLs last (§3.6).
|
|
func TestSeriesNumberFloatOrderingValues(t *testing.T) {
|
|
if !(pgtype.Float8{Float64: 1}.Float64 < pgtype.Float8{Float64: 1.5}.Float64) {
|
|
t.Fatal("1 must sort before 1.5")
|
|
}
|
|
}
|
|
|
|
// ResolveSeriesSort composes the toolbar's base key + direction into
|
|
// the registry key the SQL cascade implements. "recent" is both a base
|
|
// key AND a full registry key (meaning desc) — the composition must
|
|
// run BEFORE the full-key passthrough, or Date Added can never sort
|
|
// ascending (2026-10-27 regression the direction toggle exposed).
|
|
func TestResolveSeriesSortComposition(t *testing.T) {
|
|
cases := []struct {
|
|
sort, dir string
|
|
wantKey, wantD string
|
|
}{
|
|
// THE regression: Date Added + toggle up.
|
|
{"recent", "asc", "recent_asc", "asc"},
|
|
{"recent", "", "recent", "desc"},
|
|
{"recent", "desc", "recent", "desc"},
|
|
{"recent", "bogus", "recent", "desc"},
|
|
// Full keys (back-compat URLs / JSON API) win as-is.
|
|
{"name_desc", "", "name_desc", "desc"},
|
|
{"name_asc", "desc", "name_asc", "asc"},
|
|
// Base keys compose; default direction applies when dir absent.
|
|
{"name", "", "name_asc", "asc"},
|
|
{"name", "desc", "name_desc", "desc"},
|
|
{"books", "", "books_desc", "desc"},
|
|
{"books", "asc", "books_asc", "asc"},
|
|
{"last_read", "asc", "last_read_asc", "asc"},
|
|
// Unknown/empty → default.
|
|
{"", "", "recent", "desc"},
|
|
{"bogus", "asc", "recent", "desc"},
|
|
}
|
|
for _, tc := range cases {
|
|
key, dir := ResolveSeriesSort(tc.sort, tc.dir)
|
|
if key != tc.wantKey || dir != tc.wantD {
|
|
t.Errorf("ResolveSeriesSort(%q, %q) = (%q, %q), want (%q, %q)",
|
|
tc.sort, tc.dir, key, dir, tc.wantKey, tc.wantD)
|
|
}
|
|
}
|
|
}
|