Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med `gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory". Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står lige der. Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk `keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af den, der ikke kan. Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer, identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`. Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et sted i noten, hvor som helst, i hvilket som helst ord. `fold` er `title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker titlen, brødteksten og "gron" for "grøn" på én gang. Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i mine opgaver" er den næste melding, og svaret på den ville være denne linje. Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede noter er det fire megabyte og et par millisekunder, og den kører kun, når den hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en større scanning. Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene, som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille forsvundet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The point of the whole thing: a #tag in a note is the same tag as in a task.
|
|
// Not a second kind that looks alike and has to be kept in step by hand.
|
|
func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
n := &Note{
|
|
CreatedBy: userID,
|
|
Body: "Mødet med #Firma gik fint. Følg op sammen med #Regnskab.",
|
|
}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var projects []string
|
|
for _, l := range n.Links {
|
|
if l.Kind == "project" {
|
|
projects = append(projects, l.TargetID)
|
|
}
|
|
}
|
|
if len(projects) != 2 {
|
|
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
|
}
|
|
// Folded: the key is a key, not a label. #firma and #Firma are the same project
|
|
// to everybody except a database, and the project's own spelling is on the
|
|
// project, which is what the interface shows.
|
|
if projects[0] != "firma" || projects[1] != "regnskab" {
|
|
t.Errorf("got %v", projects)
|
|
}
|
|
}
|
|
|
|
// Backwards is the direction that makes it a second brain rather than a folder.
|
|
func TestANoteIsFoundFromWhatItPointsAt(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
const taskID = "01a01694-a25c-7000-a50f-d72ab33f9b6a"
|
|
notes := []*Note{
|
|
{CreatedBy: userID, Body: "Aftalt på mødet, se /opgave/" + taskID},
|
|
{CreatedBy: userID, Body: "Intet at gøre med den sag."},
|
|
}
|
|
for _, n := range notes {
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
found, err := db.NotesLinking(ctx, "task", taskID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("%d notes point at the task, want 1", len(found))
|
|
}
|
|
if !strings.HasPrefix(found[0].Title, "Aftalt") {
|
|
t.Errorf("the wrong note came back: %q", found[0].Title)
|
|
}
|
|
}
|
|
|
|
// The links are an index over the text, so editing the text must move them.
|
|
// A stale link is worse than none: it says a connection exists that does not.
|
|
func TestEditingANoteRewritesWhatItPointsAt(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
n := &Note{CreatedBy: userID, Body: "Handler om #Firma"}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before, _ := db.NotesLinking(ctx, "project", "Firma"); len(before) != 1 {
|
|
t.Fatal("the first save did not record the link")
|
|
}
|
|
|
|
n.Body = "Handler om #Regnskab i stedet"
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stale, err := db.NotesLinking(ctx, "project", "Firma")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(stale) != 0 {
|
|
t.Errorf("the note still points at Firma after the mention was removed")
|
|
}
|
|
if fresh, _ := db.NotesLinking(ctx, "project", "Regnskab"); len(fresh) != 1 {
|
|
t.Errorf("the new mention was not recorded")
|
|
}
|
|
}
|
|
|
|
// A list of untitled notes is a list nobody can read.
|
|
func TestANoteTakesItsTitleFromItsFirstLine(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
for _, c := range []struct{ body, want string }{
|
|
{"# Møde med Anders\n\nHan ville gerne...", "Møde med Anders"},
|
|
{"\n\n Løse tanker \nmere", "Løse tanker"},
|
|
{"", ""},
|
|
} {
|
|
n := &Note{CreatedBy: userID, Body: c.body}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.Title != c.want {
|
|
t.Errorf("body %q gave title %q, want %q", c.body, n.Title, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Danish spelling, both ways round — the same generosity tasks already get.
|
|
func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
// The title is the first line now, so that is where it goes.
|
|
if err := db.SaveNote(ctx, &Note{
|
|
CreatedBy: userID,
|
|
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, q := range []string{"grønt", "gront", "århus", "aarhus", "regnskab"} {
|
|
found, err := db.SearchNotes(ctx, q, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Errorf("%q found %d notes, want 1", q, len(found))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deleting is the trash, the way it is everywhere else in this program. A note is
|
|
// the one place somebody can lose an hour of writing to one keystroke.
|
|
func TestADeletedNoteCanComeBack(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
n := &Note{CreatedBy: userID, Body: "Vigtigt"}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.DeleteNote(ctx, n.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gone, _ := db.Note(ctx, n.ID); gone != nil {
|
|
t.Error("a deleted note is still returned")
|
|
}
|
|
if err := db.RestoreNote(ctx, n.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := db.Note(ctx, n.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back == nil || back.Body != "Vigtigt" {
|
|
t.Error("the note did not come back whole")
|
|
}
|
|
}
|
|
|
|
func TestWhatIsReadOutOfABody(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name, body string
|
|
want []NoteLink
|
|
}{
|
|
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
|
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
|
{"the same tag twice is one link", "#Firma og #Firma igen",
|
|
[]NoteLink{{"project", "firma"}}},
|
|
{"two spellings are one tag", "#Firma og #firma",
|
|
[]NoteLink{{"project", "firma"}}},
|
|
{"a heading is not a tag", "# Overskrift", nil},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := LinksIn(c.body)
|
|
if len(got) != len(c.want) {
|
|
t.Fatalf("got %v, want %v", got, c.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("got %v, want %v", got[i], c.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The number beside a project is what is left to do: not finished, not deleted.
|
|
// It replaced a count of people, which said 2 on an empty project.
|
|
func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
p := &Project{Name: "Tallene", OwnerID: userID}
|
|
if err := db.CreateProject(ctx, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
count := func() int {
|
|
list, err := db.ListProjects(ctx, userID, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, x := range list {
|
|
if x.ID == p.ID {
|
|
return x.OpenCount
|
|
}
|
|
}
|
|
t.Fatal("the project is not in the list")
|
|
return -1
|
|
}
|
|
|
|
if n := count(); n != 0 {
|
|
t.Errorf("an empty project counts %d", n)
|
|
}
|
|
|
|
var ids []string
|
|
for _, c := range []string{"en", "to", "tre"} {
|
|
task := &Task{ProjectID: p.ID, Content: c, CreatedBy: userID}
|
|
if err := db.CreateTask(ctx, task, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids = append(ids, task.ID)
|
|
}
|
|
if n := count(); n != 3 {
|
|
t.Errorf("three tasks count %d", n)
|
|
}
|
|
|
|
if _, err := db.CompleteTask(ctx, ids[0], userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := count(); n != 2 {
|
|
t.Errorf("after finishing one, the count is %d, want 2", n)
|
|
}
|
|
|
|
if err := db.DeleteTask(ctx, ids[1]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := count(); n != 1 {
|
|
t.Errorf("after deleting one, the count is %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// The title follows the first line, always — not only the first time.
|
|
//
|
|
// Derived once and then left alone, it goes stale the moment somebody rewrites
|
|
// the opening, and the list ends up calling a note by a name that is nowhere in
|
|
// it. That is exactly what happened: a note headed "Ny note" was listed as
|
|
// "fdgfgfgh", which was what its first line had been some edits ago.
|
|
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.Title != "Første udgave" {
|
|
t.Fatalf("title started as %q", n.Title)
|
|
}
|
|
|
|
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.Title != "Ny note" {
|
|
t.Errorf("after rewriting the first line the title is %q", n.Title)
|
|
}
|
|
|
|
back, err := db.Note(ctx, n.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Title != "Ny note" {
|
|
t.Errorf("the stored title is %q", back.Title)
|
|
}
|
|
|
|
// And a title cannot be set beside the text, because then the two can disagree.
|
|
n.Title = "noget andet"
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n.Title != "Ny note" {
|
|
t.Errorf("a title set by hand survived as %q", n.Title)
|
|
}
|
|
}
|
|
|
|
// A note that names a project in the other case must still turn up on it.
|
|
//
|
|
// It did not, and nothing said why: the key was stored exactly as typed and
|
|
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
|
|
// different things that looked identical to a person.
|
|
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
if err := db.SaveNote(ctx, &Note{
|
|
CreatedBy: userID,
|
|
Body: "Bønnerne kommer fredag #garageristeriet",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
|
|
found, err := db.NotesLinking(ctx, "project", spelling)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
|
|
}
|
|
}
|
|
|
|
// A note title is a title, not a key: two notes called "Møde" and "møde" are
|
|
// two notes, and folding them together would merge things somebody kept apart.
|
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
|
|
t.Error("a note title was folded")
|
|
}
|
|
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
|
|
t.Error("a note title did not match itself")
|
|
}
|
|
}
|
|
|
|
// En søgning skal svare med det, der passer bedst, øverst.
|
|
//
|
|
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
|
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
|
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
|
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
|
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
|
// fra brugerens plads er præcis dét.
|
|
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
|
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
|
for _, body := range []string{
|
|
"# Tesla Model Y\n\nBilen selv.\n",
|
|
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
|
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
|
} {
|
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 3 {
|
|
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
|
}
|
|
if found[0].Title != "Tesla Model Y" {
|
|
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
|
}
|
|
}
|
|
|
|
// A word inside a word.
|
|
//
|
|
// Reported as "søgning har ikke det store held": a note holding
|
|
// `gamerule keepInventory true` could not be found by typing "keep inventory".
|
|
// The index is built of tokens, and `keepInventory` is one token — so the two
|
|
// words are nowhere, and the search says there is nothing while the note is right
|
|
// there.
|
|
//
|
|
// A decade of notes is full of these: commands, identifiers, file names. Every one
|
|
// of them is several words the index thinks is one.
|
|
func TestAWordInsideAWordIsStillFound(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
if err := db.SaveNote(ctx, &Note{
|
|
CreatedBy: userID,
|
|
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A second note, so a fallback that simply returns everything would be caught.
|
|
if err := db.SaveNote(ctx, &Note{
|
|
CreatedBy: userID,
|
|
Body: "Indkøb\n\nKaffe og filtre",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, q := range []string{
|
|
"keep inventory", // the two words, which is how somebody looks for it
|
|
"inventory", // the tail on its own — no prefix query can reach this
|
|
"keepinventory", // written as one, which the index does hold
|
|
"KEEP INVENTORY", // and case is not a question anybody is asking
|
|
"vacuum", // not in either note
|
|
} {
|
|
found, err := db.SearchNotes(ctx, q, 10)
|
|
if err != nil {
|
|
t.Fatalf("%q: %v", q, err)
|
|
}
|
|
want := 1
|
|
if q == "vacuum" {
|
|
want = 0
|
|
}
|
|
if len(found) != want {
|
|
t.Errorf("%q found %d notes, want %d", q, len(found), want)
|
|
continue
|
|
}
|
|
if want == 1 && found[0].Title != "Minecraft Bedrock" {
|
|
t.Errorf("%q found %q", q, found[0].Title)
|
|
}
|
|
}
|
|
|
|
// The fast path is still the fast path: a word the index does hold must not be
|
|
// answered by the scan, or the ranking that was built for twelve hundred notes
|
|
// is quietly gone.
|
|
found, err := db.SearchNotes(ctx, "Bedrock", 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("an ordinary word found %d notes", len(found))
|
|
}
|
|
}
|