package index
import (
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
// forumIndex folds n threads, each one post, a day apart so the order
// is unambiguous.
func forumIndex(t *testing.T, n int) *Index {
t.Helper()
ix, err := Open(filepath.Join(t.TempDir(), "i.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
var batch []Parsed
for i := 0; i < n; i++ {
h := strings.Repeat("0", 60) + string("0123456789abcdef"[i%16]) + string("0123456789abcdef"[(i/16)%16]) + "ab"
batch = append(batch, Parsed{Hash: h, Art: &artifact.Artifact{
Type: artifact.Forum, ThreadTitle: "thread", User: "dan",
Date: "2026-09-" + string(rune('0'+(i/10)%10)) + string(rune('0'+i%10)) + "T10:00:00",
}})
}
if err := ix.FoldNow(batch); err != nil {
t.Fatal(err)
}
return ix
}
// Walking the cursor visits every thread exactly once, in the same
// order an unpaged read gives. A keyset cursor that is off by one
// either repeats a row at every boundary or drops one, and both look
// fine on a single page.
func TestWalkingTheForumCursorSeesEachThreadOnce(t *testing.T) {
ix := forumIndex(t, 25)
all, err := ix.ForumThreads(0, "")
if err != nil {
t.Fatal(err)
}
if len(all) != 25 {
t.Fatalf("FAIL: unpaged read gave %d threads, want 25", len(all))
}
var walked []string
before := ""
for pages := 0; pages < 20; pages++ {
page, err := ix.ForumThreads(7, before)
if err != nil {
t.Fatal(err)
}
if len(page) == 0 {
break
}
for _, th := range page {
walked = append(walked, th.Root)
}
before = ForumCursor(page[len(page)-1])
}
if len(walked) != len(all) {
t.Fatalf("FAIL: walked %d threads, the forum holds %d", len(walked), len(all))
}
for i := range all {
if walked[i] != all[i].Root {
t.Fatalf("FAIL: paging diverged from the listing's order at row %d", i)
}
}
seen := map[string]bool{}
for _, r := range walked {
if seen[r] {
t.Fatalf("FAIL: %s was returned twice", r[:12])
}
seen[r] = true
}
}
// A limit returns at most that many, and zero means everything — which
// is what the hot sort needs, because it ranks across the whole forum
// before it can say what the first page is.
func TestALimitBoundsThePageAndZeroMeansAll(t *testing.T) {
ix := forumIndex(t, 25)
if got, _ := ix.ForumThreads(10, ""); len(got) != 10 {
t.Errorf("FAIL: limit 10 gave %d", len(got))
}
if got, _ := ix.ForumThreads(0, ""); len(got) != 25 {
t.Errorf("FAIL: limit 0 gave %d, want every thread", len(got))
}
if got, _ := ix.ForumThreads(500, ""); len(got) != 25 {
t.Errorf("FAIL: a limit past the end gave %d", len(got))
}
}
// The count does not build the threads it counts.
func TestTheCountMatchesTheListing(t *testing.T) {
ix := forumIndex(t, 25)
n, err := ix.ForumThreadCount()
if err != nil {
t.Fatal(err)
}
all, _ := ix.ForumThreads(0, "")
if n != len(all) {
t.Errorf("FAIL: count says %d, the listing has %d", n, len(all))
}
}
// A cursor that is empty or malformed starts at the beginning rather
// than erroring: a stale link is a page, not a 500.
func TestAnUnreadableCursorStartsAtTheTop(t *testing.T) {
ix := forumIndex(t, 5)
for _, c := range []string{"", "nonsense", "no-separator-here"} {
got, err := ix.ForumThreads(0, c)
if err != nil {
t.Fatalf("FAIL: cursor %q errored: %v", c, err)
}
if len(got) != 5 {
t.Errorf("FAIL: cursor %q gave %d threads, want the whole listing", c, len(got))
}
}
}