package web
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// Principle 3: the plain link is the whole feature. The preview rides
// ON the same anchors, armed once for the page, so a reader without
// script still navigates.
//
// The container carries the behaviour; an anchor carries only its
// token. Three handlers for the whole page, whatever it holds โ a chat
// log with two hundred references pays for them once (RFC 0011 ยง14,
// Principle 4).
func TestTheContainerCarriesTheHandlersNotTheAnchors(t *testing.T) {
// The page arms nothing per anchor and nothing on <body>: one script,
// loaded once, listens on the document.
if strings.Contains(styleSource, "data-on:mouseover") || strings.Contains(styleSource, "data-on:focusin") {
t.Fatal("the layout still arms the preview with inline handlers")
}
if n := strings.Count(styleSource, `{{.RefPopURL}}`); n != 1 {
t.Fatalf("the layout loads the preview script %d times, want 1", n)
}
src := string(refPopJS)
for _, want := range []string{
// The event is resolved to the anchor it reached.
`closest("a[data-ref]")`,
// The token is read off the anchor and encoded at fetch time;
// nothing author-written is ever interpolated.
`encodeURIComponent(a.dataset.ref)`, `/ref.frag?fmt=html&ref=`,
`document.body.dataset.refRepo`,
// Hover and focus are for pointers that hover; a tap opens the card elsewhere.
`matchMedia("(hover: hover)")`, `preventDefault`,
// Fetch on intent, cache per ref, drop stale answers, empty before filling.
`var cache = {}`, `if (cur !== a) return;`, `pop.replaceChildren()`, `clear();`,
`"focusin"`, `"Escape"`, `showPopover`, `anchorName`, `getBoundingClientRect`,
} {
if !strings.Contains(src, want) {
t.Fatalf("refpop.js misses %q", want)
}
}
// The rows this file draws are references without being prose, so
// they carry the one attribute a reference in prose does โ and no
// handlers of their own.
quote := string(noHooks.chatQuoteHTML(ChatMessage{Ref: 412, RefUser: "dan", RefLine: "a line"}, "demo"))
if !strings.Contains(quote, `data-quote-seq="412"`) || !strings.Contains(quote, `data-ref="#412"`) {
t.Fatalf("the reply-quote is not armed like a reference:\n%s", quote)
}
if strings.Contains(quote, "data-on:") {
t.Fatalf("the reply-quote arms itself:\n%s", quote)
}
row := string(chatRefsHTML(chatRefsRow{Count: 2, Seq: 7}, "demo"))
if !strings.Contains(row, `data-ref="#7"`) || strings.Contains(row, "data-on:") {
t.Fatalf("the backlink row is not armed like a reference:\n%s", row)
}
}
// The shared popover is positioned against the link that opened it,
// with the measured fallback only where CSS anchoring is unknown.
func TestPopoverIsAnchoredNotCentred(t *testing.T) {
rule := styleSource[strings.Index(styleSource, "#refpop { position: fixed"):]
rule = rule[:strings.Index(rule, "\n}")]
for _, want := range []string{
"#refpop { position: fixed; inset: auto",
"position-anchor: " + refAnchorName,
"top: anchor(bottom)",
} {
if !strings.Contains(rule, want) {
t.Fatalf("#refpop is not anchored to its link, missing %q:\n%s", want, rule)
}
}
}
// One popover element for the whole page: a per-link popover is a
// per-link element, and the page grows one for every reference in it.
func TestBaseLayoutHasOneSharedPopover(t *testing.T) {
if n := strings.Count(styleSource, `id="refpop"`); n != 1 {
t.Fatalf("base layout declares %d #refpop elements, want 1", n)
}
if !strings.Contains(styleSource, `<div id="refpop" popover`) {
t.Fatalf("the shared preview is not a native popover:\n%s", styleSource)
}
}
// Each card says what it is, structurally, and offers the page it
// previews. The copy is not asserted; the hooks are.
func TestRefCardCarriesKindAndOpenLink(t *testing.T) {
for _, tt := range []struct{ kind, href string }{
{"chat", "/r/demo/chat/412"},
{"checkin", "/r/demo/checkin/abc"},
{"ticket", "/r/demo/ticket/xy9"},
{"landing", "/r/demo/landing/ab"},
{"forum", "/r/demo/forum/ab"},
{"wiki", "/r/demo/wiki/Home"},
{"person", "/people/alice"},
} {
got := string(refCardHTML(refCard{Kind: tt.kind, Href: tt.href, Title: "t"}))
if !strings.Contains(got, `data-kind="`+tt.kind+`"`) {
t.Fatalf("%s card lost its data-kind:\n%s", tt.kind, got)
}
if !strings.Contains(got, `href="`+tt.href+`"`) {
t.Fatalf("%s card lost its open link:\n%s", tt.kind, got)
}
}
// An aged-out chat reference says so as a state, not as prose the
// caller has to parse.
got := string(refCardHTML(refCard{Kind: "chat", Href: "/r/demo/chat/1", Expired: true}))
if !strings.Contains(got, `data-expired`) {
t.Fatalf("expired card carries no state hook:\n%s", got)
}
// A person card never carries an address (RFC 0004 keeps those
// behind `e`), so there is nothing shaped like one to leak.
person := string(refCardHTML(refCard{Kind: "person", Href: "/people/alice", Title: "alice"}))
if strings.Contains(person, "mailto:") || strings.Contains(person, "@") {
t.Fatalf("person card leaked an address:\n%s", person)
}
}
// The reply-quote: a message posted with Ref shows who it answers and
// the first line of what they said, linked to the permalink.
func TestReplyRendersItsQuote(t *testing.T) {
got := string(noHooks.chatMsgHTML(ChatMessage{
Seq: 9, Kind: "msg", User: "bob", Text: "agreed", Ref: 4,
RefUser: "alice", RefLine: "the first line",
}, "demo", "", false))
for _, want := range []string{
`class="chat-quote"`,
`data-quote-seq="4"`,
`href="/r/demo/chat/4"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("reply quote missing %q:\n%s", want, got)
}
}
}
// The original says how many answered it, and the count links to the
// permalink where they are.
func TestOriginalShowsItsReplyCount(t *testing.T) {
got := string(noHooks.chatMsgHTML(ChatMessage{
Seq: 4, Kind: "msg", User: "alice", Text: "hi", Replies: 3,
}, "demo", "", false))
if !strings.Contains(got, `data-replies="3"`) {
t.Fatalf("reply count hook missing:\n%s", got)
}
if !strings.Contains(got, `href="/r/demo/chat/4"`) {
t.Fatalf("reply count does not link to the permalink:\n%s", got)
}
}
// A message with no replies grows no count element at all: "0 replies"
// is noise on every line of a busy channel.
func TestNoRepliesRendersNoCount(t *testing.T) {
got := string(noHooks.chatMsgHTML(ChatMessage{Seq: 4, Kind: "msg", User: "alice", Text: "hi"}, "demo", "", false))
if strings.Contains(got, "data-replies") {
t.Fatalf("a message with no replies grew a count:\n%s", got)
}
}
// The fragment route: one card, wrapped so the shared popover comes
// back still being a popover.
func TestRefFragServesTheCard(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
fc := &webChatStub{msgs: []chat.Message{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "hello", At: "2026-08-16T12:00:00Z"},
}}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Chat: fc,
Forum: &webForumReadStub{},
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/ref.frag?ref=%231", nil))
body := rec.Body.String()
for _, want := range []string{
"event: datastar-patch-elements",
`<div id="refpop" popover>`,
`data-kind="chat"`,
`href="/r/test/chat/1"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("ref.frag missing %q:\n%s", want, body)
}
}
if rec.Header().Get("ETag") == "" {
t.Fatalf("ref.frag served no ETag:\n%v", rec.Header())
}
// A repeat hover on an unchanged reference costs a 304, not a redraw.
rec2 := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/r/test/ref.frag?ref=%231", nil)
req.Header.Set("If-None-Match", rec.Header().Get("ETag"))
h.ServeHTTP(rec2, req)
if rec2.Code != http.StatusNotModified {
t.Fatalf("unchanged reference re-rendered: %d", rec2.Code)
}
// The page's script asks for the card alone, as HTML: no event
// frame, no popover wrapper, so it can fill the shared element itself
// once the content is in hand.
rec3 := httptest.NewRecorder()
h.ServeHTTP(rec3, httptest.NewRequest("GET", "/r/test/ref.frag?fmt=html&ref=%231", nil))
card := rec3.Body.String()
if !strings.HasPrefix(card, `<div class="refcard"`) || strings.Contains(card, "datastar") || strings.Contains(card, `id="refpop"`) ||
!strings.Contains(rec3.Header().Get("Content-Type"), "text/html") || !strings.Contains(card, `href="/r/test/chat/1"`) {
t.Fatalf("fmt=html is not the bare card:\n%s", card)
}
}
// Principle 3, asserted on a real page: every reference on the chat
// page is an anchor with a real href, so navigation never depends on
// the Datastar attributes sitting beside it.
func TestChatPageReferencesNavigateWithoutScript(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
fc := &webChatStub{msgs: []chat.Message{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "start", At: "2026-08-16T12:00:00Z"},
{Seq: 2, Kind: chat.KindMsg, User: "ann", Text: "see #1", Ref: 1, At: "2026-08-16T12:00:01Z"},
}}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Chat: fc,
Forum: &webForumReadStub{},
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil))
body := rec.Body.String()
// The reference minted by the resolver, and the quote block.
if !strings.Contains(body, `data-ref="#1"`) {
t.Fatalf("chat page did not link a #seq reference:\n%s", body)
}
if !strings.Contains(body, `data-quote-seq="1"`) {
t.Fatalf("reply did not render its quote:\n%s", body)
}
if !strings.Contains(body, `data-replies="1"`) {
t.Fatalf("original did not show its reply count:\n%s", body)
}
// Every marked reference carries a real destination: Principle 3
// says the link is the feature and the card is the accelerator.
for _, tag := range markedAnchors.FindAllString(body, -1) {
if !strings.Contains(tag, `href="/`) {
t.Fatalf("a reference has no navigable href: %s", tag)
}
}
}
// The backlink row on an artifact page: its own row, its own hook, and
// a link to the latest message โ visibly not one of the durable
// mention rows beside it.
func TestArtifactPagesShowTheChatRow(t *testing.T) {
for _, tt := range []struct {
name string
tmpl *pageTemplate
data pageData
}{
{"ticket", ticketPage, pageData{Repo: "demo", Ticket: ticketDetail{ID: "xy9"}}},
{"checkin", checkinPage, pageData{Repo: "demo", Checkin: checkinDetail{Hash: "abc"}}},
{"forum", forumThreadPage, pageData{Repo: "demo", ForumRoot: "abc"}},
} {
tt.data.ChatRefs = chatRefsRow{Count: 2, Seq: 7, At: "2026-08-29T10:00:00Z"}
var buf strings.Builder
if err := noHooks.template(tt.tmpl).Execute(&buf, tt.data); err != nil {
t.Fatalf("%s: %v", tt.name, err)
}
got := buf.String()
if !strings.Contains(got, `data-chat-refs data-count="2" data-seq="7"`) {
t.Fatalf("%s page has no chat row:\n%s", tt.name, got)
}
if !strings.Contains(got, `href="/r/demo/chat/7"`) {
t.Fatalf("%s chat row does not link the latest message", tt.name)
}
}
}
// Nothing named it, nothing shown: an empty row on every artifact page
// is noise that teaches readers to skip the place the real one appears.
func TestNoChatRowWhenNothingNamedIt(t *testing.T) {
var buf strings.Builder
if err := noHooks.template(ticketPage).Execute(&buf, pageData{Repo: "demo", Ticket: ticketDetail{ID: "xy9"}}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), "data-chat-refs") {
t.Fatalf("an empty chat row was drawn")
}
}
// markedAnchors finds the opening <a> tags markup marked as references.
var markedAnchors = regexp.MustCompile(`<a [^>]*\bdata-ref="[^"]*"[^>]*>`)
// A landing's card carries its number, title and state as hooks.
func TestALandingCardCarriesItsNumber(t *testing.T) {
html := string(refCardHTML(refCard{Kind: "landing", Title: "t", State: "open", Number: 12, Href: "/r/q/landing/ab"}))
for _, want := range []string{`data-kind="landing"`, `data-number="12"`, `data-state="open"`, `class="refcard-title"`} {
if !strings.Contains(html, want) {
t.Fatalf("FAIL: the landing card lacks %s: %s", want, html)
}
}
t.Log("ok: kind, number, state and title ride the card")
}