#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

This commit is contained in:
Sansan committed 2026-09-26 16:48:40 +02:00
1 parent e86c0f8e04
commit a8a2602f50
34 files changed
+1819 -1294

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ package web
templ Base() {
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8"/>
<title>Music Search</title>
+29 -355
View File
@@ -4,40 +4,23 @@ import (
"log"
"music-server/internal/backend"
"net/http"
"regexp"
"sort"
"strings"
"sync"
"unicode"
)
var soundtracks_added []string
// Precomputed data for optimization
type SoundtrackData struct {
Original string
Cleaned string
Abbreviation string
}
var (
precomputedSoundtracks []SoundtrackData
precomputedOnce sync.Once
regexCache = make(map[string]*regexp.Regexp)
regexCacheMutex sync.Mutex
)
func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
search_term := r.FormValue("search_term")
search(search_term)
results := soundtracks_added
component := FoundSoundtracks(soundtracks_added)
component := FoundSoundtracks(results)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -47,357 +30,48 @@ func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) {
func search(searchText string) {
soundtracks_added = nil
soundtrackData := backend.GetSearchCache()
results := searchWithData(searchText, soundtrackData)
soundtracks_added = results
}
// Precompute cleaned search term once
cleanedSearchTerm := clean_term(searchText)
// searchWithData is a testable version of search that takes soundtrack data as a parameter
func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string {
var results []string
seen := make(map[string]bool)
// Use precomputed data for efficiency
soundtrackData := getPrecomputedSoundtracks()
seen := make(map[string]bool) // O(1) duplicate checking
// Precompute cleaned and lowercase search terms once
cleanedSearchTerm := strings.ToLower(searchText)
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and")
cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and")
lowercaseSearchText := strings.ToLower(searchText)
// Pass 1: Exact match (case-insensitive contains)
for _, data := range soundtrackData {
// Check exact match (case-insensitive contains)
if is_match_exact(searchText, data.Original) && !seen[data.Original] {
soundtracks_added = append(soundtracks_added, data.Original)
if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) {
results = append(results, data.Original)
seen[data.Original] = true
}
}
// Pass 2: Contains match with cleaned terms
for _, data := range soundtrackData {
// Check contains match with cleaned terms
if !seen[data.Original] && is_match_contains(cleanedSearchTerm, data.Cleaned) {
soundtracks_added = append(soundtracks_added, data.Original)
if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) {
results = append(results, data.Original)
seen[data.Original] = true
}
}
// Pass 3: Regex match with cleaned terms
for _, data := range soundtrackData {
// Check regex match with cleaned terms
if !seen[data.Original] && is_match_regex_cached(cleanedSearchTerm, data.Cleaned) {
soundtracks_added = append(soundtracks_added, data.Original)
if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) {
results = append(results, data.Original)
seen[data.Original] = true
}
}
}
func is_match_exact(search_term string, soundtrack_name string) bool {
search_term = strings.ToLower(search_term)
soundtrack_name = strings.ToLower(soundtrack_name)
if search_term == "" {
return true
} else if strings.Contains(soundtrack_name, search_term) {
return true
} else {
return false
}
}
func is_match_contains(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
} else if strings.Contains(soundtrack_name, search_term) {
return true
} else {
return false
}
}
func is_match_regex(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
} else if compile_regex(search_term).MatchString(soundtrack_name) {
return true
} else {
return false
}
}
func compile_regex(search_term string) *regexp.Regexp {
regText := ".*"
for _, letter := range search_term {
regText += string(letter) + ".*"
}
r, _ := regexp.Compile(regText)
return r
}
// is_match_regex_cached uses cached compiled regex patterns for better performance
func is_match_regex_cached(search_term string, soundtrack_name string) bool {
if search_term == "" {
return true
}
// Check cache first
regexCacheMutex.Lock()
re, exists := regexCache[search_term]
if !exists {
re = compile_regex(search_term)
regexCache[search_term] = re
}
regexCacheMutex.Unlock()
return re.MatchString(soundtrack_name)
}
func clean_term(term string) string {
term = strings.ReplaceAll(term, " ", "")
term = strings.ReplaceAll(term, "é", "e")
term = strings.ReplaceAll(term, "+", "plus")
term = strings.ReplaceAll(term, "&", "and")
term = strings.ReplaceAll(term, "'n", "and")
return strings.ToLower(term)
}
// toLower converts a string to lowercase.
func toLower(s string) string {
return strings.Map(unicode.ToLower, s)
}
// levenshteinWithThreshold calculates Levenshtein distance with early termination and space optimization.
// Uses O(min(n,m)) space instead of O(n*m) and stops early if threshold is exceeded.
func levenshteinWithThreshold(s, t string, threshold int) int {
if threshold < 0 {
threshold = 2 // default threshold
}
m, n := len(s), len(t)
// Quick checks for early termination
if m == 0 {
return n
}
if n == 0 {
return m
}
if abs(m-n) > threshold {
return threshold + 1 // Can't be within threshold
}
// Use the shorter string for the row to minimize space
if m < n {
s, t = t, s
m, n = n, m
}
// Space optimization: only store two rows
prevRow := make([]int, n+1)
currRow := make([]int, n+1)
// Initialize first row
for j := 0; j <= n; j++ {
prevRow[j] = j
}
for i := 1; i <= m; i++ {
currRow[0] = i
minInRow := currRow[0]
for j := 1; j <= n; j++ {
if s[i-1] == t[j-1] {
currRow[j] = prevRow[j-1]
} else {
currRow[j] = min3(prevRow[j], currRow[j-1], prevRow[j-1]) + 1
}
if currRow[j] < minInRow {
minInRow = currRow[j]
}
}
// Early termination: if minimum in current row exceeds threshold
if minInRow > threshold {
return threshold + 1
}
// Swap rows for next iteration
prevRow, currRow = currRow, prevRow
}
return prevRow[n]
}
// Helper functions for optimized Levenshtein
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// precomputeSoundtrackData initializes cleaned terms and abbreviations for all soundtracks
func precomputeSoundtrackData() {
precomputedOnce.Do(func() {
soundtracks := backend.GetAllSoundtracks()
precomputedSoundtracks = make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks {
cleaned := clean_term(soundtrack)
precomputedSoundtracks[i] = SoundtrackData{
Original: soundtrack,
Cleaned: cleaned,
Abbreviation: extractAbbreviation(soundtrack),
}
}
})
}
// getPrecomputedSoundtracks ensures precomputed data is available and returns it
func getPrecomputedSoundtracks() []SoundtrackData {
precomputeSoundtrackData()
return precomputedSoundtracks
}
// levenshtein maintains backward compatibility by calling the optimized version
func levenshtein(s, t string) int {
return levenshteinWithThreshold(s, t, -1) // -1 uses default threshold
}
// extractAbbreviation extracts the first letters of each word in a string.
func extractAbbreviation(s string) string {
words := strings.Fields(s)
abbr := ""
for _, word := range words {
if len(word) > 0 {
abbr += string(word[0])
}
}
return toLower(abbr)
}
// fuzzyFindSubstring checks if the query fuzzy-matches any substring of the item.
func fuzzyFindSubstring(query, item string, threshold int) (bool, int) {
query = toLower(query)
item = toLower(item)
queryLen := len(query)
itemLen := len(item)
if queryLen > itemLen {
return false, 0
}
// Check for exact substring match first (weight: 100)
if strings.Contains(item, query) {
return true, 100
}
// Check for fuzzy substring match using optimized Levenshtein
for i := 0; i <= itemLen-queryLen; i++ {
substring := item[i : i+queryLen]
distance := levenshteinWithThreshold(query, substring, threshold)
if distance <= threshold {
// Weight: higher for matches at the start of the string
weight := 50 - i // Higher weight for earlier matches
return true, weight
}
}
return false, 0
}
// fuzzyFind checks if the query matches the item (substring or abbreviation).
func fuzzyFind(query string, item string, threshold int) (bool, int) {
query = toLower(query)
item = toLower(item)
// Check for substring fuzzy match
if matched, weight := fuzzyFindSubstring(query, item, threshold); matched {
return true, weight
}
// Check for abbreviation match using optimized Levenshtein
abbr := extractAbbreviation(item)
distance := levenshteinWithThreshold(query, abbr, threshold)
if distance <= threshold {
// Weight: higher for exact abbreviation matches
weight := 100 - distance*10 // Higher weight for exact matches
return true, weight
}
return false, 0
}
// getAdaptiveThreshold returns a threshold based on query length
func getAdaptiveThreshold(query string) int {
threshold := 2 // Base threshold for minor typos
queryLen := len(query)
if queryLen > 6 {
// For longer queries, allow more tolerance
threshold = queryLen / 3
if threshold < 2 {
threshold = 2
} else if threshold > 4 {
threshold = 4 // Cap at 4 for very long queries
}
}
return threshold
}
// fuzzySearch performs fuzzy search on soundtracks using the cleaned terms for consistency
func fuzzySearch(searchText string) []string {
query := clean_term(searchText)
threshold := getAdaptiveThreshold(query)
// Use precomputed data for efficiency
soundtrackData := getPrecomputedSoundtracks()
type match struct {
item string
weight int
}
var matches []match
seen := make(map[string]bool) // O(1) duplicate checking
for _, data := range soundtrackData {
if matched, weight := fuzzyFind(query, data.Cleaned, threshold); matched {
if !seen[data.Original] {
matches = append(matches, match{data.Original, weight})
seen[data.Original] = true
}
}
}
// Sort matches by weight (descending)
sort.Slice(matches, func(i, j int) bool {
return matches[i].weight > matches[j].weight
})
// Extract just the soundtrack names in order
result := make([]string, len(matches))
for i, m := range matches {
result[i] = m.item
}
return result
}
// FindSoundtrackFuzzyWebHandler handles fuzzy search requests
func FindSoundtrackFuzzyWebHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
search_term := r.FormValue("search_term")
results := fuzzySearch(search_term)
component := FoundSoundtracks(results)
err = component.Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Fatalf("Error rendering in FindSoundtrackFuzzyWebHandler: %e", err)
}
return results
}
+8 -38
View File
@@ -3,49 +3,20 @@ package web
templ SearchForm() {
@Base() {
<button id="dark-mode-toggle">🌙</button>
<div id="search-container">
<input id="search_term" name="search_term" type="text" hx-post="/findfuzzy" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<div class="radio-group" style="display: inline-block; margin-left: 10px;">
<label><input type="radio" name="search_type" value="fuzzy" checked> Fuzzy</label>
<label><input type="radio" name="search_type" value="normal"> Normal</label>
</div>
<button type="button" id="clear" name="clear">Clear</button>
</div>
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
<button type="button" id="clear" name="clear">Clear</button>
<div id="soundtracks-container"></div>
<script>
// Get current search type from radio buttons
function getSearchType() {
return document.querySelector('input[name="search_type"]:checked').value;
}
// Get endpoint based on search type
function getSearchEndpoint() {
return getSearchType() === 'fuzzy' ? '/findfuzzy' : '/find';
}
// Update search input endpoint
function updateSearchEndpoint() {
const endpoint = getSearchEndpoint();
document.getElementById('search_term').setAttribute('hx-post', endpoint);
}
document.addEventListener('readystatechange', () => {
if (document.readyState == 'complete') {
// Initialize with fuzzy search (default)
htmlx.ajax('POST', '/findfuzzy', '#soundtracks-container');
// Initialize search
htmlx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus();
// Add event listeners for radio buttons
document.querySelectorAll('input[name="search_type"]').forEach(radio => {
radio.addEventListener('change', updateSearchEndpoint);
});
// Initialize dark mode from localStorage (default to dark)
// Initialize theme from localStorage (default to dark)
const savedTheme = localStorage.getItem('theme') || 'dark';
if (savedTheme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('dark-mode-toggle').textContent = '☀️';
}
document.documentElement.setAttribute('data-theme', savedTheme);
document.getElementById('dark-mode-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
}
});
@@ -64,8 +35,7 @@ templ SearchForm() {
document.getElementById("clear").addEventListener("click", function (event) {
document.getElementById("search_term").value = "";
// Use current search type endpoint
htmlx.ajax('POST', getSearchEndpoint(), '#soundtracks-container');
htmlx.ajax('POST', '/find', '#soundtracks-container');
document.getElementById("search_term").focus();
});
</script>
+144 -85
View File
@@ -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