package api
import (
"context"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
)
// A robot row stored before lines named things by number renders the
// landing reference hook with the landing's number; a person's line
// that happens to say "landing <hash>" is left exactly as written.
func TestOldRobotLinesRenderAsNumberedRefs(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "r.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
lid := strings.Repeat("d4", 20)
if err := ix.FoldNow([]index.Parsed{
{Hash: lid, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "Land it", Date: "2026-08-18T09:00:00", User: "dan",
Body: []byte("zit-landing: 1\nbranch: b\ntarget: trunk\n\nmessage\n")}},
}); err != nil {
t.Fatal(err)
}
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := db.LandingNumbersAssign("demo", []string{lid}); err != nil {
t.Fatal(err)
}
ll := func(_ string, n int) (string, bool) { return lid, n == 1 }
ln := func(_, id string) int {
if id == lid {
return 1
}
return 0
}
prevL, prevN := landingByNumber.Swap(&ll), landingNumberOfRef.Swap(&ln)
t.Cleanup(func() { landingByNumber.Store(prevL); landingNumberOfRef.Store(prevN) })
d := Deps{LandingNumbers: db, CanRead: func(context.Context, string) bool { return true },
CapsFor: func(_, _ string) CapSet { return fakeCaps(anyCapLetters) }}
repo := &Repo{Index: ix}
render := func(kind, text string) chatMsgOut {
return d.chatRenderAll(context.Background(), repo, "demo", []chat.Message{{Seq: 1, Kind: kind, User: "robot", Text: text}})[0]
}
const hook = `data-ref-kind="landing"`
typed := render(chat.KindMsg, "!1 opened by zeitgeist")
if !strings.Contains(typed.HTML, hook) {
t.Fatalf("typed !1 has no landing hook: %s", typed.HTML)
}
old := render(chat.KindRobot, "landing "+lid[:12]+" opened by zeitgeist")
if old.HTML != typed.HTML {
t.Errorf("old robot row differs from the typed reference:\n%s\n%s", old.HTML, typed.HTML)
}
if len(old.Refs) != 1 {
t.Errorf("refs = %v, want the one landing", old.Refs)
}
unknown := render(chat.KindRobot, "landing "+strings.Repeat("0", 12)+" opened by zeitgeist")
if strings.Contains(unknown.HTML, hook) {
t.Errorf("an unknown hash became a reference: %s", unknown.HTML)
}
human := render(chat.KindMsg, "landing "+lid[:12]+" opened by zeitgeist")
if strings.Contains(human.HTML, hook) {
t.Errorf("a human line was rewritten: %s", human.HTML)
}
}
type countLandings struct {
LandingNumberStore
n atomic.Int32
}
func (c *countLandings) LandingNumbers(repo string) (map[string]int, error) {
c.n.Add(1)
return c.LandingNumberStore.LandingNumbers(repo)
}
type countTickets struct {
TicketNumberStore
n atomic.Int32
}
func (c *countTickets) TicketNumbers(repo string) (map[string]int, error) {
c.n.Add(1)
return c.TicketNumberStore.TicketNumbers(repo)
}
// A page of old robot rows loads each repository's numbers once per
// noun, not once per row.
func TestOldRobotRowsLoadNumbersOncePerRepoAndNoun(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "r.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
lid, tid := strings.Repeat("d4", 20), strings.Repeat("a1", 20)
if err := ix.FoldNow([]index.Parsed{
{Hash: lid, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "Land it", Date: "2026-08-18T09:00:00", User: "dan",
Body: []byte("zit-landing: 1\nbranch: b\ntarget: trunk\n\nmessage\n")}},
{Hash: strings.Repeat("c1", 20), Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
Date: "2026-08-15T10:00:00", User: "dan", Fields: []artifact.Field{{Name: "title", Value: "one"}}}},
}); err != nil {
t.Fatal(err)
}
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := db.LandingNumbersAssign("demo", []string{lid}); err != nil {
t.Fatal(err)
}
repo := &Repo{Index: ix}
// Numbered first, so the counts below are reads and not a backfill.
Deps{Numbers: db}.ticketNumbers("demo", repo)
run := func(rows int) (landings, tickets int32) {
cl, ct := &countLandings{LandingNumberStore: db}, &countTickets{TicketNumberStore: db}
d := Deps{LandingNumbers: cl, Numbers: ct}
var msgs []chat.Message
for i := 0; i < rows; i++ {
msgs = append(msgs,
chat.Message{Seq: uint64(2*i + 1), Kind: chat.KindRobot, User: "robot", Text: "landing " + lid[:12] + " opened"},
chat.Message{Seq: uint64(2*i + 2), Kind: chat.KindRobot, User: "robot", Text: "ticket " + tid[:12] + " closed"})
}
d.chatRenderAll(context.Background(), repo, "demo", msgs)
return cl.n.Load(), ct.n.Load()
}
l1, t1 := run(1)
l5, t5 := run(5)
if l1 != 1 || l5 != 1 {
t.Errorf("landing map loads = %d for one row, %d for five, want 1 and 1", l1, l5)
}
if t1 == 0 || t5 != t1 {
t.Errorf("ticket map loads = %d for one row, %d for five, want equal and nonzero", t1, t5)
}
}
// A mirror numbers nothing itself but shows what its upstream recorded;
// an old robot row on one resolves through that, as a typed #N does.
func TestOldRobotRowOnAMirrorResolvesTheRecordedTicketNumber(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "r.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
tid := strings.Repeat("a1", 20)
if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("c1", 20), Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: tid,
Date: "2026-08-15T10:00:00", User: "dan", Fields: []artifact.Field{{Name: "number", Value: "7"}}}}}); err != nil {
t.Fatal(err)
}
d := Deps{}
got := d.robotNumbered(&robotNums{}, &Repo{Index: ix}, "mirror", "ticket "+tid[:12]+" closed")
if !strings.HasPrefix(got, "ticket #7 ") {
t.Fatalf("robotNumbered = %q, want the recorded number as ticket #7", got)
}
}