Files
vikunja/pkg/models/task_search_test.go
kolaente 8a79c30477 chore(search): consolidate scoring comments and deduplicate test helpers
The ranking-gate comment still claimed title and description weigh
equally, which the 1.5x boost made wrong; scoring semantics now live only
at the boost call site. Test subtests share searchTaskPositions instead
of hand-building position maps four times.
2026-07-06 15:45:55 +00:00

339 lines
13 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestKanbanViewBucketFiltering(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
view, err := GetProjectViewByID(s, 4)
require.NoError(t, err)
project, err := GetProjectSimpleByID(s, view.ProjectID)
require.NoError(t, err)
buckets, err := GetTasksInBucketsForView(s, view, []*Project{project}, &taskSearchOptions{}, &user.User{ID: 1})
require.NoError(t, err)
taskBuckets := map[int64][]int64{}
for _, b := range buckets {
for _, tsk := range b.Tasks {
taskBuckets[tsk.ID] = append(taskBuckets[tsk.ID], b.ID)
}
}
for tid, bs := range taskBuckets {
assert.Lenf(t, bs, 1, "task %d appears in multiple buckets: %v", tid, bs)
}
for _, id := range []int64{40, 41, 42, 43, 44, 45, 46} {
assert.NotContains(t, taskBuckets, id)
}
}
// TestTaskSearchRelevanceRanking verifies that a multi-word search ranks the task
// matching all words above tasks matching only some. The ranking uses ParadeDB's
// relevance score and is therefore only enforced on ParadeDB; on other databases
// we only assert that the matching tasks are returned (no order guarantee),
// keeping the test green across the whole CI database matrix.
func TestTaskSearchRelevanceRanking(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usr := &user.User{ID: 1}
allWords := &Task{Title: "Backup server migration", ProjectID: 1}
require.NoError(t, allWords.Create(s, usr))
oneWordA := &Task{Title: "Backup of old files", ProjectID: 1}
require.NoError(t, oneWordA.Create(s, usr))
oneWordB := &Task{Title: "server room booking", ProjectID: 1}
require.NoError(t, oneWordB.Create(s, usr))
// Created last on purpose: it has the highest id, so the default id-asc order
// would place it after the one-word matches. It ranking above them can only
// come from relevance scoring, not from the fallback ordering.
lateAllWords := &Task{Title: "server backup checklist", ProjectID: 1}
require.NoError(t, lateAllWords.Create(s, usr))
assertRelevanceRanked := func(t *testing.T, tc *TaskCollection) {
pos := searchTaskPositions(t, s, usr, tc, "backup server")
require.Contains(t, pos, allWords.ID, "the task matching all words should be returned")
if db.ParadeDBAvailable() {
// Compare only the tasks created by this test so fixture tasks (present
// or future) matching the search cannot break the order assertions.
for _, id := range []int64{oneWordA.ID, oneWordB.ID, lateAllWords.ID} {
require.Contains(t, pos, id, "all created tasks should match the search")
}
for _, allw := range []int64{allWords.ID, lateAllWords.ID} {
for _, onew := range []int64{oneWordA.ID, oneWordB.ID} {
assert.Less(t, pos[allw], pos[onew], "tasks matching all query words should rank above one-word matches by relevance")
}
}
}
}
// Without a view: plain "tasks.*, pdb.score(tasks.id)" select.
t.Run("no view", func(t *testing.T) {
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1})
})
// With a view: exercises the task_positions LEFT JOIN, which adds
// task_positions.position to the DISTINCT select alongside pdb.score(tasks.id).
t.Run("list view", func(t *testing.T) {
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1, ProjectViewID: 1})
})
// An explicit sort_by must win over relevance: with `id desc` the lowest-id
// task (allWords) ranks last, the opposite of what relevance ranking would do.
// This locks the contract that user-provided sorting disables relevance
// ranking even on ParadeDB. Only ParadeDB's per-token search matches all
// three tasks, so the ordering contract is only asserted there (other
// databases ILIKE the whole phrase and match a different subset).
t.Run("explicit sort disables relevance ranking", func(t *testing.T) {
if !db.ParadeDBAvailable() {
t.Skip("relevance ranking only applies on ParadeDB")
}
pos := searchTaskPositions(t, s, usr, &TaskCollection{
ProjectID: 1,
SortBy: []string{"id"},
OrderBy: []string{"desc"},
}, "backup server")
for _, id := range []int64{allWords.ID, oneWordA.ID, oneWordB.ID} {
require.Contains(t, pos, id, "all created tasks should match the search")
}
assert.Less(t, pos[oneWordB.ID], pos[oneWordA.ID], "tasks must follow the explicit id-desc sort, not relevance")
assert.Less(t, pos[oneWordA.ID], pos[allWords.ID], "the all-words match (lowest id) ranks last under id-desc, proving relevance was not applied")
})
// The all-projects scope appends the Favorites pseudo-project whenever the user
// has any favorited task (fixtures give user 1 tasks 1 and 15). Both live in
// projects the user can access, so the favorites arm is redundant, gets dropped
// and the global search stays relevance-ranked.
t.Run("global search with in-scope favorites", func(t *testing.T) {
assertRelevanceRanked(t, &TaskCollection{})
})
// Task 13 lives in project 2, which user 1 cannot access: the favorites arm is
// load-bearing, so the query keeps it and falls back to unranked ordering
// instead of failing with an unsupported-query-shape error on ParadeDB.
t.Run("global search with out-of-scope favorite stays unranked", func(t *testing.T) {
outOfScopeFavorite := &Favorite{EntityID: 13, UserID: usr.ID, Kind: FavoriteKindTask}
_, err := s.Insert(outOfScopeFavorite)
require.NoError(t, err)
defer func() {
_, err := s.Delete(outOfScopeFavorite)
require.NoError(t, err)
}()
pos := searchTaskPositions(t, s, usr, &TaskCollection{}, "backup server")
require.Contains(t, pos, allWords.ID)
})
// sort_by=done,relevance keeps undone tasks first and ranks by relevance within
// each group — the quick-actions search shape. On databases that cannot score,
// the relevance param is dropped and the done,id ordering remains.
t.Run("explicit done and relevance sort", func(t *testing.T) {
doneAllWords := &Task{Title: "Backup server runbook", ProjectID: 1, Done: true}
require.NoError(t, doneAllWords.Create(s, usr))
pos := searchTaskPositions(t, s, usr, &TaskCollection{
ProjectID: 1,
SortBy: []string{"done", "relevance"},
}, "backup server")
require.Contains(t, pos, allWords.ID)
require.Contains(t, pos, doneAllWords.ID)
assert.Less(t, pos[allWords.ID], pos[doneAllWords.ID], "undone tasks must come before done tasks")
if db.ParadeDBAvailable() {
// doneAllWords matches all query words: only the done sort taking
// precedence over its high relevance score can place it last.
for _, undone := range []int64{allWords.ID, oneWordA.ID, oneWordB.ID, lateAllWords.ID} {
require.Contains(t, pos, undone)
assert.Less(t, pos[undone], pos[doneAllWords.ID], "undone tasks must come before the done all-words match")
}
for _, allw := range []int64{allWords.ID, lateAllWords.ID} {
for _, onew := range []int64{oneWordA.ID, oneWordB.ID} {
assert.Less(t, pos[allw], pos[onew], "within the undone group, all-words matches must rank above one-word matches")
}
}
}
})
// A numeric search cannot be scored; the relevance param must be dropped
// instead of producing an unsupported-query-shape error.
t.Run("relevance sort with numeric search falls back", func(t *testing.T) {
pos := searchTaskPositions(t, s, usr, &TaskCollection{
ProjectID: 1,
SortBy: []string{"relevance"},
}, "#1")
assert.NotEmpty(t, pos)
})
}
// TestTaskSearchMatching pins ParadeDB's matching contract: per-token matching
// across title and description, word-order independence, and fuzzy matching
// within edit distance 1 against token prefixes (fuzzy(1, t)). Other databases
// fall back to a substring match of the whole search string over each field and
// intentionally match less, so this test is ParadeDB-only.
func TestTaskSearchMatching(t *testing.T) {
if !db.ParadeDBAvailable() {
t.Skip("requires ParadeDB")
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usr := &user.User{ID: 1}
newTask := func(title, description string) *Task {
tsk := &Task{Title: title, Description: description, ProjectID: 1}
require.NoError(t, tsk.Create(s, usr))
return tsk
}
helloWorld := newTask("hello world", "")
greenOrange := newTask("what is going on with the green orange two", "and some other data")
orangeTape := newTask("orange twosided tape and so twoish", "sensible data is here to test")
meeting := newTask("weekly meeting notes", "discuss the quarterly budget forecast")
ficus := newTask("untitled chore", "watering the ficus plant")
k8s := newTask("kubernetes cluster maintenance", "")
created := []*Task{helloWorld, greenOrange, orangeTape, meeting, ficus, k8s}
tests := []struct {
name string
search string
want []*Task
}{
{"prefix match", "Hell", []*Task{helloWorld}},
// helloWorld matches too: fuzzy and prefix compose, "wo" is one edit
// from "two" and a prefix of "world".
{"prefix match across tasks", "two", []*Task{greenOrange, orangeTape, helloWorld}},
{"all words in one field", "Hello World", []*Task{helloWorld}},
{"typo one edit away", "Hello orld", []*Task{helloWorld}},
{"typo in one of two words", "kubernets cluster", []*Task{k8s}},
{"description-only match", "data", []*Task{greenOrange, orangeTape}},
{"description-only single task", "ficus", []*Task{ficus}},
{"terms split between title and description", "meeting budget", []*Task{meeting}},
{"terms split across tasks' fields", "data orange", []*Task{greenOrange, orangeTape}},
{"word order reversed", "orange green", []*Task{greenOrange, orangeTape}},
{"word order as written", "green orange", []*Task{greenOrange, orangeTape}},
{"no matching token", "xylophone", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pos := searchTaskPositions(t, s, usr, &TaskCollection{ProjectID: 1}, tt.search)
want := map[int64]bool{}
for _, tsk := range tt.want {
want[tsk.ID] = true
}
// Only the created tasks are asserted on, fixture matches are ignored.
for _, tsk := range created {
if want[tsk.ID] {
assert.Contains(t, pos, tsk.ID, "search %q should match task %q", tt.search, tsk.Title)
} else {
assert.NotContains(t, pos, tsk.ID, "search %q should not match task %q", tt.search, tsk.Title)
}
}
})
}
// Regardless of word order, the task matching both words must rank above
// the task matching only one.
for _, query := range []string{"green orange", "orange green"} {
t.Run("ranking "+query, func(t *testing.T) {
pos := searchTaskPositions(t, s, usr, &TaskCollection{ProjectID: 1}, query)
require.Contains(t, pos, greenOrange.ID)
require.Contains(t, pos, orangeTape.ID)
assert.Less(t, pos[greenOrange.ID], pos[orangeTape.ID], "the task matching both words should rank first")
})
}
}
// searchTaskPositions runs a search and returns each returned task's position
// in the result order, keyed by task id.
func searchTaskPositions(t *testing.T, s *xorm.Session, usr *user.User, tc *TaskCollection, query string) map[int64]int {
t.Helper()
got, _, _, err := tc.ReadAll(s, usr, query, 1, 50)
require.NoError(t, err)
gotTasks, is := got.([]*Task)
require.True(t, is)
pos := map[int64]int{}
for i, tsk := range gotTasks {
pos[tsk.ID] = i
}
return pos
}
// TestTaskSearchTitleBoost pins the 1.5x title boost: a title match ranks above
// a description match, but never above a task matching more query words.
func TestTaskSearchTitleBoost(t *testing.T) {
if !db.ParadeDBAvailable() {
t.Skip("requires ParadeDB")
}
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usr := &user.User{ID: 1}
titleHit := &Task{Title: "wombat expedition", ProjectID: 1}
require.NoError(t, titleHit.Create(s, usr))
descHit := &Task{Title: "some errand", Description: "planning the wombat visit", ProjectID: 1}
require.NoError(t, descHit.Create(s, usr))
descBoth := &Task{Title: "chores", Description: "wombat capybara sightings", ProjectID: 1}
require.NoError(t, descBoth.Create(s, usr))
t.Run("title match ranks above description match", func(t *testing.T) {
pos := searchTaskPositions(t, s, usr, &TaskCollection{ProjectID: 1}, "wombat")
require.Contains(t, pos, titleHit.ID)
require.Contains(t, pos, descHit.ID)
assert.Less(t, pos[titleHit.ID], pos[descHit.ID], "a boosted title match (1.5) must outrank a description match (1.0)")
})
t.Run("more matched words beat the title boost", func(t *testing.T) {
pos := searchTaskPositions(t, s, usr, &TaskCollection{ProjectID: 1}, "wombat capybara")
require.Contains(t, pos, descBoth.ID)
require.Contains(t, pos, titleHit.ID)
assert.Less(t, pos[descBoth.ID], pos[titleHit.ID], "two description matches (2.0) must outrank one boosted title match (1.5)")
})
}