#9 Major routes refactor and expansion, #15 Added API for statistics, Added search cache backend and functionality tests, Backend sync improvements
Build / build (push) Successful in 56s
Build / build (push) Successful in 56s
This commit is contained in:
1 parent
e86c0f8e04
commit
a8a2602f50
34 files changed
+1819
-1294
No files matched your search
+144
-85
@@ -1,49 +1,18 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"music-server/internal/backend"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtractAbbreviation tests that abbreviations are correctly extracted from soundtrack names
|
||||
func TestExtractAbbreviation(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// extractAbbreviation takes first letter of each word (split by spaces)
|
||||
{"Metal Gear Solid 4", "mgs4"},
|
||||
{"Resident Evil 2", "re2"},
|
||||
// "Final Fantasy VII" -> "Final", "Fantasy", "VII" -> "f" + "f" + "v" = "ffv"
|
||||
{"Final Fantasy VII", "ffv"},
|
||||
// "Ys III - Wanderers from Ys (+The Oath in Felghana)" ->
|
||||
// "Ys", "III", "-", "Wanderers", "from", "Ys", "(+The", "Oath", "in", "Felghana)"
|
||||
// -> "y" + "i" + "-" + "w" + "f" + "y" + "(" + "o" + "i" + "f" = "yi-wfy(oif"
|
||||
{"Ys III - Wanderers from Ys (+The Oath in Felghana)", "yi-wfy(oif"},
|
||||
// "Call of Duty: Modern Warfare" -> "Call", "of", "Duty:", "Modern", "Warfare" -> "c" + "o" + "d" + "m" + "w" = "codmw"
|
||||
{"Call of Duty: Modern Warfare", "codmw"},
|
||||
// "Half-Life 3" -> "Half-Life", "3" -> "h" + "3" = "h3"
|
||||
{"Half-Life 3", "h3"},
|
||||
{"Metal Gear Solid 5", "mgs5"},
|
||||
{"A", "a"},
|
||||
{"", ""},
|
||||
{"Single", "s"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := extractAbbreviation(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractAbbreviation(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanTerm tests that search terms are cleaned correctly
|
||||
func TestCleanTerm(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// clean_term removes spaces, replaces specific chars, and lowercases
|
||||
// CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases
|
||||
{"MGS4", "mgs4"},
|
||||
{"RE2", "re2"},
|
||||
{"FFVII", "ffvii"},
|
||||
@@ -52,7 +21,7 @@ func TestCleanTerm(t *testing.T) {
|
||||
{"Test 123", "test123"},
|
||||
{"A+B", "aplusb"},
|
||||
{"é", "e"},
|
||||
// Note: clean_term does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||
// Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and"
|
||||
{"It's", "it's"},
|
||||
// But it does replace "'n" with "and"
|
||||
{"I'n", "iand"},
|
||||
@@ -60,75 +29,165 @@ func TestCleanTerm(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := clean_term(tt.input)
|
||||
result := backend.CleanSearchTermForMatch(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("clean_term(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFuzzyFindAbbreviationMatches tests that abbreviations match the correct soundtracks
|
||||
func TestFuzzyFindAbbreviationMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
query string
|
||||
item string
|
||||
expected bool
|
||||
}{
|
||||
// Positive cases - should match
|
||||
{"MGS4", "Metal Gear Solid 4", true},
|
||||
{"RE2", "Resident Evil 2", true},
|
||||
{"FFVII", "Final Fantasy VII", true},
|
||||
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)", true},
|
||||
{"MGS", "Metal Gear Solid 4", true},
|
||||
{"RE", "Resident Evil 2", true},
|
||||
{"FF", "Final Fantasy VII", true},
|
||||
|
||||
// Note: With fuzzy matching (threshold=2), similar items may also match.
|
||||
// For example, "MGS4" will also match "Metal Gear Solid 5" because
|
||||
// the abbreviations "mgs4" and "mgs5" have Levenshtein distance of 1.
|
||||
// This is intentional for fuzzy matching to allow minor typos.
|
||||
// The user's requirement is that the CORRECT item is found, not that
|
||||
// INCORRECT items are excluded. Results are sorted by weight, so the
|
||||
// exact match should appear first.
|
||||
}
|
||||
|
||||
threshold := 2 // Default threshold
|
||||
|
||||
for _, tt := range tests {
|
||||
matched, _ := fuzzyFind(tt.query, tt.item, threshold)
|
||||
if matched != tt.expected {
|
||||
t.Errorf("fuzzyFind(%q, %q, %d) = %v, want %v", tt.query, tt.item, threshold, matched, tt.expected)
|
||||
// searchWithDataHelper is a helper to create backend.SoundtrackData from original names
|
||||
func searchWithDataHelper(originals []string) []backend.SoundtrackData {
|
||||
data := make([]backend.SoundtrackData, len(originals))
|
||||
for i, orig := range originals {
|
||||
data[i] = backend.SoundtrackData{
|
||||
Original: orig,
|
||||
Lowercase: strings.ToLower(orig),
|
||||
Cleaned: backend.CleanSearchTermForMatch(orig),
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestFuzzyFindWithAllTestData tests all the user's requirements comprehensively
|
||||
// The user's requirement is that each query SHOULD find the specified item.
|
||||
// Fuzzy matching may also find similar items, but the correct one should be in the results.
|
||||
func TestFuzzyFindWithAllTestData(t *testing.T) {
|
||||
// TestNormalSearch tests the normal search function
|
||||
func TestNormalSearch(t *testing.T) {
|
||||
// Set up comprehensive test data with all user-provided soundtracks
|
||||
testData := searchWithDataHelper([]string{
|
||||
"A Bird Story",
|
||||
"Actraiser",
|
||||
"Actraiser 2",
|
||||
"Bangai-O Spirits",
|
||||
"Ciconia When they Cry",
|
||||
"Civilization IV",
|
||||
"Civilization VI",
|
||||
"Clair Obscur - Expedition 33",
|
||||
"Club House Games (42 All-Time Classics)",
|
||||
"Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)",
|
||||
"Final Fantasy VII Crisis Core",
|
||||
"Final Fantasy VII Rebirth",
|
||||
"Final Fantasy VII Remake",
|
||||
"Final Fantasy VII",
|
||||
"Joe & Mac",
|
||||
"Judgment",
|
||||
"Kingdom Hearts",
|
||||
"Kingdom Hearts Birth By Sleep",
|
||||
"Kingdom Hearts II",
|
||||
"Kingdom Hearts III",
|
||||
"Lemmings (SNES + Mega Drive)",
|
||||
"Lemmings 3D",
|
||||
"Mega Man X4",
|
||||
"Metal Gear Rising - Revengeance",
|
||||
"Metal Gear Solid (+Twin Snakes)",
|
||||
"Metal Gear Solid 2 - Sons of Liberty",
|
||||
"Metal Gear Solid 3 - Snake Eater",
|
||||
"Metal Gear Solid 4 - Guns of the Patriots",
|
||||
"Metroid Prime",
|
||||
"Octopath Traveler",
|
||||
"Octopath Traveler 2",
|
||||
"Ori and the Blind Forest",
|
||||
"Ori and the Will of the Wisps",
|
||||
"OutRun 2006 - Coast 2 Coast",
|
||||
"Paper Mario - The Thousand Year Door",
|
||||
"Pokémon HeartGold & SoulSilver",
|
||||
"Resident Evil 2 (Remake)",
|
||||
"Shenmue",
|
||||
"Super Mario Galaxy",
|
||||
"Trails in the Sky FC (Sora no Kiseki FC)",
|
||||
"Trails in the Sky SC (Sora no Kiseki SC)",
|
||||
"Turrican II - The Final Fight",
|
||||
"Umineko no Naku Koro ni",
|
||||
"Umineko no Naku Koro ni Chiru",
|
||||
"VVVVVV",
|
||||
"YS VIII - Lacrimosa of DANA",
|
||||
"Yakuza 0",
|
||||
"Ys (Chronicles)",
|
||||
"Ys - Memories of Celceta",
|
||||
"Ys - The Ark of Napishtim",
|
||||
"Ys II (Chronicles)",
|
||||
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
|
||||
"Ys IV - The Dawn of Ys",
|
||||
"Ys Origin",
|
||||
"Ys Seven",
|
||||
"Ys X Nordics",
|
||||
"Call of Duty: Modern Warfare",
|
||||
"Half-Life 3",
|
||||
})
|
||||
|
||||
// Test cases covering all match types
|
||||
tests := []struct {
|
||||
query string
|
||||
wantMatch string
|
||||
wantFound string
|
||||
}{
|
||||
{"MGS4", "Metal Gear Solid 4"},
|
||||
{"RE2", "Resident Evil 2"},
|
||||
{"FFVII", "Final Fantasy VII"},
|
||||
// Exact substring matches
|
||||
{"Metal", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"Resident", "Resident Evil 2 (Remake)"},
|
||||
{"Final", "Final Fantasy VII"},
|
||||
{"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
}
|
||||
|
||||
threshold := 2
|
||||
// Contains matches (on cleaned terms)
|
||||
{"gotp", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"mgs", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"kh", "Kingdom Hearts"},
|
||||
{"mg", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
|
||||
// Regex matches
|
||||
{"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
{"re2r", "Resident Evil 2 (Remake)"},
|
||||
{"ffv", "Final Fantasy VII"},
|
||||
{"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"},
|
||||
{"codmw", "Call of Duty: Modern Warfare"},
|
||||
{"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
// Verify the query matches the expected item
|
||||
matched, _ := fuzzyFind(tt.query, tt.wantMatch, threshold)
|
||||
if !matched {
|
||||
t.Errorf("Query %q did not match expected item %q", tt.query, tt.wantMatch)
|
||||
results := searchWithData(tt.query, testData)
|
||||
|
||||
// Check that the expected item is in the results
|
||||
found := false
|
||||
for _, result := range results {
|
||||
if result == tt.wantFound {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't test fuzzySearch() directly because it depends on precomputed data
|
||||
// from the database. The unit tests above verify the underlying functions that
|
||||
// fuzzySearch() uses: extractAbbreviation, clean_term, and fuzzyFind.
|
||||
// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches
|
||||
func TestSearchOrdering(t *testing.T) {
|
||||
// "MG" matches Pass 1 (exact substring on original)
|
||||
// "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear")
|
||||
testData := searchWithDataHelper([]string{
|
||||
"Metal Gear",
|
||||
"MG",
|
||||
})
|
||||
|
||||
results := searchWithData("mg", testData)
|
||||
|
||||
// Find indices
|
||||
indexPass1 := -1
|
||||
indexPass2 := -1
|
||||
for i, result := range results {
|
||||
if result == "MG" {
|
||||
indexPass1 = i
|
||||
}
|
||||
if result == "Metal Gear" {
|
||||
indexPass2 = i
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1 match (MG) should come before Pass 2 match (Metal Gear)
|
||||
if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 {
|
||||
t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The tests above verify:
|
||||
// 1. clean_term - the term cleaning logic
|
||||
// 2. search - the complete normal search with exact, contains, and regex matching
|
||||
// 3. ordering - exact matches come before contains matches
|
||||
Reference in new issue
Block a user