package index
import (
"reflect"
"testing"
"craftdesign.group/quarry/internal/artifact"
)
func foldForum(t *testing.T, ix *Index, hash, root, parent, title, date string) {
t.Helper()
if err := ix.FoldNow([]Parsed{{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Forum, ThreadRoot: root, InReplyTo: parent,
ThreadTitle: title, Date: date, User: "dan", Body: []byte("hi"),
}}}); err != nil {
t.Fatal(err)
}
}
// Votes are keyed on full post hashes while pages link with prefixes;
// the resolver bridges the two and also answers which thread a post
// belongs to, so a vote row can carry its thread without trusting the
// form data.
func TestForumPostThreadResolvesPrefix(t *testing.T) {
ix := openIndex(t)
foldForum(t, ix, "aaaa1111", "", "", "hello", "2026-01-01T10:00:00")
foldForum(t, ix, "bbbb2222", "aaaa1111", "aaaa1111", "", "2026-01-02T10:00:00")
post, thread, ok, err := ix.ForumPostThread("bbbb")
if err != nil || !ok {
t.Fatalf("resolve reply: ok=%v err=%v", ok, err)
}
if post != "bbbb2222" || thread != "aaaa1111" {
t.Fatalf("reply resolved to post=%q thread=%q", post, thread)
}
// An opener roots its own thread.
if _, thread, _, _ := ix.ForumPostThread("aaaa"); thread != "aaaa1111" {
t.Fatalf("opener thread = %q, want itself", thread)
}
if _, _, ok, err := ix.ForumPostThread("ffff"); ok || err != nil {
t.Fatalf("absent prefix: ok=%v err=%v, want false nil", ok, err)
}
}
func foldForumBody(t *testing.T, ix *Index, hash, root, title, date, body string) {
t.Helper()
if err := ix.FoldNow([]Parsed{{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Forum, ThreadRoot: root, ThreadTitle: title,
Date: date, User: "dan", Body: []byte(body),
}}}); err != nil {
t.Fatal(err)
}
}
// Anchors extract at fold and resolve only at query: the backlink query
// asks by the page's own full hash, so a stored ref surfaces exactly on
// the pages it prefixes — the honesty gate is structural.
func TestThreadsMentioning(t *testing.T) {
ix := openIndex(t)
const target = "4bd6301f22bd6301f22bd6301f22bd63"
foldForumBody(t, ix, "aaaa1111", "", "Broken build", "2026-01-01T10:00:00",
"bisected to [4bd6301f22] which landed yesterday")
foldForumBody(t, ix, "bbbb2222", "", "Naming decade debate", "2026-01-02T10:00:00",
"a decade of efface and facade — hexy words, no digits")
foldForumBody(t, ix, "cccc3333", "aaaa1111", "", "2026-01-03T10:00:00",
"also see deadbeef99 maybe")
threads, err := ix.ThreadsMentioning(target)
if err != nil {
t.Fatal(err)
}
if len(threads) != 1 || threads[0].Root != "aaaa1111" {
t.Fatalf("mentions of %s = %+v, want thread aaaa1111", target, threads)
}
if threads[0].Title != "Broken build" || threads[0].Posts != 2 {
t.Fatalf("backlink row = %+v", threads[0])
}
// Hex-spelling English never anchors: no digits, no ref.
if got, _ := ix.ThreadsMentioning("decade00decade00decade00"); len(got) != 0 {
t.Fatalf("digit-free word anchored: %+v", got)
}
// A ref that prefixes nothing on this page stays silent.
if got, _ := ix.ThreadsMentioning("ffffffffffffffff"); len(got) != 0 {
t.Fatalf("unrelated hash has mentions: %+v", got)
}
// Re-folding the same post must not duplicate its anchors.
foldForumBody(t, ix, "aaaa1111", "", "Broken build", "2026-01-01T10:00:00",
"bisected to [4bd6301f22] which landed yesterday")
if got, _ := ix.ThreadsMentioning(target); len(got) != 1 {
t.Fatalf("re-fold duplicated anchors: %+v", got)
}
}
// A forum edit is a new artifact whose P card names the version it
// replaces. The thread shows one post per chain — the newest text in
// the original's place — and replies made to an older version still
// hang off it. The history has every version, oldest first.
func TestForumEditShowsOnlyTheLatestVersion(t *testing.T) {
ix := openIndex(t)
fold := func(hash, root, parent, prior, body, date string) {
t.Helper()
var parents []string
if prior != "" {
parents = []string{prior}
}
if err := ix.FoldNow([]Parsed{{Hash: hash, Art: &artifact.Artifact{
Type: artifact.Forum, ThreadRoot: root, InReplyTo: parent, Parents: parents,
Date: date, User: "dan", Body: []byte(body),
}}}); err != nil {
t.Fatal(err)
}
}
fold("aaaa1111", "", "", "", "opener", "2026-01-01T10:00:00")
fold("bbbb2222", "aaaa1111", "aaaa1111", "", "first draft", "2026-01-02T10:00:00")
fold("cccc3333", "aaaa1111", "bbbb2222", "", "a reply to the draft", "2026-01-03T10:00:00")
fold("dddd4444", "aaaa1111", "aaaa1111", "bbbb2222", "second draft", "2026-01-04T10:00:00")
fold("eeee5555", "aaaa1111", "aaaa1111", "dddd4444", "final", "2026-01-05T10:00:00")
_, posts, ok, err := ix.ForumThreadPosts("aaaa")
if err != nil || !ok {
t.Fatalf("thread: ok=%v err=%v", ok, err)
}
var hashes []string
for _, p := range posts {
hashes = append(hashes, p.Hash)
}
// Three posts, not five: the chain shows once, in the ORIGINAL's
// place (before the reply written on day 3).
if want := []string{"aaaa1111", "eeee5555", "cccc3333"}; !reflect.DeepEqual(hashes, want) {
t.Fatalf("thread shows %v, want %v", hashes, want)
}
final := posts[1]
if final.Body != "final" || final.Edits != 2 || final.Edited != "2026-01-05T10:00:00" || final.Date != "2026-01-02T10:00:00" {
t.Errorf("folded post = %+v", final)
}
if posts[2].Parent != "eeee5555" {
t.Errorf("the reply to the first draft points at %q, want the newest version", posts[2].Parent)
}
if posts[0].Edits != 0 || posts[0].Edited != "" {
t.Errorf("an unedited post claims edits: %+v", posts[0])
}
// History from ANY version's prefix, oldest first.
for _, from := range []string{"bbbb", "dddd", "eeee"} {
vs, ok, err := ix.ForumPostVersions(from)
if err != nil || !ok {
t.Fatalf("versions(%s): ok=%v err=%v", from, ok, err)
}
var got []string
for _, v := range vs {
got = append(got, v.Hash)
}
if want := []string{"bbbb2222", "dddd4444", "eeee5555"}; !reflect.DeepEqual(got, want) {
t.Errorf("versions(%s) = %v, want %v", from, got, want)
}
}
if vs, _, _ := ix.ForumPostVersions("cccc"); len(vs) != 1 {
t.Errorf("an unedited post has %d versions, want 1", len(vs))
}
}