Skip to content
quarry Artifact [92808e4d4d]
Login

Artifact [92808e4d4d]

Artifact 92808e4d4d2b6f0f299f66086cbe1914e30ef18d7118f4e6b8f2c3e5d573fa0f:


package web

import (
	"bufio"
	"context"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ops"
)

// attnStub is the attention plane the ops read, driven by the test.
type attnStub struct {
	mu   sync.Mutex
	seq  uint64
	live []chan api.AttentionEvent
	past []api.AttentionEvent
}

func (a *attnStub) Attend(_ string, e api.AttentionEvent) (uint64, error) {
	a.mu.Lock()
	a.seq++
	e.Seq = a.seq
	a.past = append(a.past, e)
	chans := append([]chan api.AttentionEvent(nil), a.live...)
	a.mu.Unlock()
	for _, ch := range chans {
		ch <- e
	}
	return e.Seq, nil
}

func (a *attnStub) TailAttention(string, uint64) (<-chan api.AttentionEvent, func()) {
	ch := make(chan api.AttentionEvent)
	a.mu.Lock()
	a.live = append(a.live, ch)
	a.mu.Unlock()
	return ch, func() {}
}

func (a *attnStub) AttentionPending(string, uint64) (int, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	return int(a.seq), nil
}

func (a *attnStub) AttentionTip(string) (uint64, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.seq, nil
}

func (a *attnStub) RecentAttention(_ string, limit int) ([]api.AttentionEvent, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	evs := append([]api.AttentionEvent(nil), a.past...)
	if limit > 0 && len(evs) > limit {
		evs = evs[len(evs)-limit:]
	}
	return evs, nil
}

type prefStub struct {
	on     bool // desktop notifications
	toasts bool // in-page banners
}

func (p *prefStub) Pref(_, key string) string {
	if key == notifyDesktopPref && p.on {
		return "on"
	}
	if key == toastsPref && p.toasts {
		return "on"
	}
	return ""
}
func (p *prefStub) SetPref(_, _, _ string) error { return nil }

func attentionHandler(t *testing.T) (*ops.Registry, *attnStub) {
	t.Helper()
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	attn := &attnStub{}
	if err := api.RegisterAll(r, api.Deps{Version: "t",
		Repos:     api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
		Attention: attn}); err != nil {
		t.Fatal(err)
	}
	return r, attn
}

func attentionServer(t *testing.T, desktop bool, extra ...Option) (*httptest.Server, *attnStub) {
	return attentionServerWith(t, &prefStub{on: desktop}, extra...)
}

func attentionServerWith(t *testing.T, prefs Prefs, extra ...Option) (*httptest.Server, *attnStub) {
	t.Helper()
	r, attn := attentionHandler(t)
	opts := append([]Option{
		WithSessions(fakeSessions{user: "dan"}),
		WithPrefs(prefs),
	}, extra...)
	srv := httptest.NewServer(HandlerWith(r, nil, nil, opts))
	t.Cleanup(srv.Close)
	return srv, attn
}

// readFrames reads SSE text until it has seen every marker or the
// deadline passes, and returns what it read.
func readFrames(t *testing.T, rd *bufio.Reader, want []string, d time.Duration) string {
	t.Helper()
	var got strings.Builder
	done := make(chan struct{})
	go func() {
		defer close(done)
		for {
			line, err := rd.ReadString('\n')
			got.WriteString(line)
			if err != nil {
				return
			}
			all := true
			for _, w := range want {
				if !strings.Contains(got.String(), w) {
					all = false
					break
				}
			}
			if all {
				return
			}
		}
	}()
	select {
	case <-done:
	case <-time.After(d):
	}
	return got.String()
}

func TestMeSSEPatchesTheBadgeAndNoToastByDefault(t *testing.T) {
	srv, attn := attentionServer(t, false)
	req, err := newSSERequest(srv.URL + "/me.sse")
	if err != nil {
		t.Fatal(err)
	}
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	rd := bufio.NewReader(resp.Body)
	// The badge on connect, before anything new arrives.
	first := readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
	if !strings.Contains(first, `id="inbox-dot"`) {
		t.Fatalf("no badge patch on connect:\n%s", first)
	}
	if _, err := attn.Attend("dan", api.AttentionEvent{
		Kind: "mention", Repo: "test", From: "eve",
		Summary: "look at this", Link: "/r/test/forum/abc",
	}); err != nil {
		t.Fatal(err)
	}
	// The badge redraw for the new event, which is the whole of what an
	// account that asked for nothing else gets.
	got := readFrames(t, rd, []string{"data: signals {attn: 1}"}, 5*time.Second)
	for _, want := range []string{
		`id="inbox-dot" class="on"`,
		"event: datastar-patch-signals",
		"data: signals {attn: 1}",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("stream missing %q:\n%s", want, got)
		}
	}
	// Banners are an opt-in, gated on the server: nothing is patched
	// into the region at all, so nothing has to be hidden.
	for _, unwanted := range []string{"data: selector #toasts", "<aside", "new Notification("} {
		if strings.Contains(got, unwanted) {
			t.Errorf("the default stream emitted %q:\n%s", unwanted, got)
		}
	}
}

// With the banner preference on, the toast arrives — and its headline
// is the REASON the event reached this person, with the message's own
// words as the muted line under it.
func TestMeSSEToastsWithThePreference(t *testing.T) {
	srv, attn := attentionServerWith(t, &prefStub{toasts: true})
	req, err := newSSERequest(srv.URL + "/me.sse")
	if err != nil {
		t.Fatal(err)
	}
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	rd := bufio.NewReader(resp.Body)
	readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
	if _, err := attn.Attend("dan", api.AttentionEvent{
		Kind: "mention", Repo: "test", From: "eve",
		Summary: "look at this", Link: "/r/test/forum/abc",
	}); err != nil {
		t.Fatal(err)
	}
	got := readFrames(t, rd, []string{"data: elements <aside"}, 5*time.Second)
	for _, want := range []string{
		`data: selector #toasts`,
		`role="status"`,
		`aria-live="polite"`,
		`class="attn-reason" href="/r/test/forum/abc"`,
		`class="attn-summary"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("toast missing %q:\n%s", want, got)
		}
	}
	if strings.Contains(got, "new Notification(") {
		t.Errorf("a Notification call was emitted with the desktop preference off:\n%s", got)
	}
}

// A summary is somebody else's prose. It reaches the toast as text and
// as a JavaScript string literal, and neither may be escapable.
func TestToastEscapesAHostileSummary(t *testing.T) {
	rec := httptest.NewRecorder()
	st := &attentionSSE{w: rec, fl: rec, toasts: true, desktop: true}
	st.toast(attentionEvent{
		Seq: 7, Kind: "mention", Repo: "te'st", From: `ev"e`,
		Summary: "</aside><script>alert(1)</script>'+alert(2)+'\nsecond line",
		Link:    `/x"><script>alert(3)</script>`,
	})
	got := rec.Body.String()
	for _, unwanted := range []string{"<script>", "</aside><", `alert(2)+'`} {
		if strings.Contains(got, unwanted) {
			t.Errorf("the toast carried %q unescaped:\n%s", unwanted, got)
		}
	}
	// And the frame is still one SSE data line: a raw newline in the
	// prose would end it early and truncate the patch.
	body := got[strings.Index(got, "data: elements "):]
	if strings.Count(strings.TrimSuffix(body, "\n\n"), "\n") != 0 {
		t.Errorf("the elements payload spans more than one data line:\n%s", got)
	}
}

func TestMeSSENotifiesOnlyWithThePreference(t *testing.T) {
	srv, attn := attentionServer(t, true)
	req, err := newSSERequest(srv.URL + "/me.sse")
	if err != nil {
		t.Fatal(err)
	}
	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	rd := bufio.NewReader(resp.Body)
	readFrames(t, rd, []string{`id="inbox-dot"`}, 5*time.Second)
	if _, err := attn.Attend("dan", api.AttentionEvent{
		Kind: "mention", Repo: "test", From: "eve", Summary: "look", Link: "/x",
	}); err != nil {
		t.Fatal(err)
	}
	// Banners are off, so the expression rides on the hidden carrier,
	// not on a toast.
	got := readFrames(t, rd, []string{`data: elements <span class="attn-notify"`}, 5*time.Second)
	if !strings.Contains(got, "new Notification(") {
		t.Errorf("the preference is on but no Notification expression was emitted:\n%s", got)
	}
	// It is still gated on the tab being in the background and on the
	// permission already having been granted — the prompt belongs to
	// the inbox control alone.
	for _, want := range []string{"document.visibilityState", "Notification.permission"} {
		if !strings.Contains(got, want) {
			t.Errorf("the Notification expression is missing %q:\n%s", want, got)
		}
	}
	if strings.Contains(got, "requestPermission") {
		t.Errorf("the stream asked for permission; only the inbox control may:\n%s", got)
	}
}

// The stream costs a connection, and plain HTTP/1.1 gives a browser
// six. It is opened on repository pages, and everywhere only when the
// deployment says it multiplexes.
func TestAttentionStreamIsGatedToRepoPages(t *testing.T) {
	r, _ := attentionHandler(t)
	h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, withCookie("/r/test/timeline"))
	if !strings.Contains(rec.Body.String(), `@get('/me.sse')`) {
		t.Error("a repository page did not open the attention stream")
	}
	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, withCookie("/"))
	if strings.Contains(rec.Body.String(), `@get('/me.sse')`) {
		t.Error("a fleet page opened the attention stream without --attention-everywhere")
	}

	everywhere := HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan"}), WithAttentionEverywhere()})
	rec = httptest.NewRecorder()
	everywhere.ServeHTTP(rec, withCookie("/"))
	body := rec.Body.String()
	if !strings.Contains(body, `@get('/me.sse')`) {
		t.Error("--attention-everywhere did not open the stream on a fleet page")
	}
	// The title reflects the count, and the region the toasts land in
	// exists whether or not anything has arrived.
	if !strings.Contains(body, `id="toasts"`) {
		t.Error("no toasts region on the page")
	}
	if !strings.Contains(body, "$attn") {
		t.Error("the document title does not reflect the attention signal")
	}
	// With the stream open the one-shot must not also fire: two ways to
	// learn one number, never both at once.
	if strings.Contains(body, `@get('/inbox.unread')`) {
		t.Error("the one-shot badge fetch fired alongside the stream")
	}
}

// Principle 3: the inbox controls are forms that work with no script at
// all. Every one of them targets `inbox.mark`, which is the op that
// clears BOTH halves of the list.
func TestInboxControlsRenderWithoutScript(t *testing.T) {
	r, _ := attentionHandler(t)
	h := HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan"}), WithPrefs(&prefStub{})})
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, withCookie("/inbox"))
	body := rec.Body.String()
	for _, want := range []string{
		`action="/inbox/mark"`,
		`name="all" value="1"`,
		`name="as" value="read"`,
		`href="/me#notifications"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("inbox page missing %q", want)
		}
	}
	// The switches moved to the preferences page; a second copy here
	// would be a second form that can disagree with the first.
	for _, gone := range []string{`action="/inbox/notify"`, `action="/inbox/toasts"`} {
		if strings.Contains(body, gone) {
			t.Errorf("inbox page still carries %q", gone)
		}
	}
}

func TestAttentionTitleAttrQuotesTheTitle(t *testing.T) {
	got := string(attentionTitleAttr("dan's ticket"))
	if !strings.Contains(got, `\&#39;`) && !strings.Contains(got, `\'`) {
		t.Fatalf("an apostrophe was not escaped into the JS literal: %s", got)
	}
	if strings.Contains(got, `+ 'dan's`) {
		t.Fatalf("the literal is unterminated: %s", got)
	}
}

func TestAttentionUnreadReadsTheOp(t *testing.T) {
	r, attn := attentionHandler(t)
	if _, err := attn.Attend("dan", api.AttentionEvent{Kind: "mention"}); err != nil {
		t.Fatal(err)
	}
	n, err := attentionUnread(ops.WithUser(context.Background(), "dan"), r)
	if err != nil {
		t.Fatal(err)
	}
	if n != 1 {
		t.Fatalf("inbox.count answered %d, want 1", n)
	}
}

// newSSERequest is a GET carrying the session cookie the fake sessions
// plane recognises.
func newSSERequest(url string) (*http.Request, error) {
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	return req, nil
}

// RFC 0011 §5: a forge with no notify companion still tells people what
// was addressed to them, and the inbox is where they read it. The rows
// come from the page's own load-time state — no stream, no script.
func TestInboxListsAttentionWithoutNotify(t *testing.T) {
	r, attn := attentionHandler(t)
	if _, err := attn.Attend("dan", api.AttentionEvent{
		Kind: "mention", Repo: "test", From: "eve",
		Summary: "look at this", Link: "/r/test/forum/abc",
	}); err != nil {
		t.Fatal(err)
	}
	h := HandlerWith(r, nil, nil, []Option{
		WithSessions(fakeSessions{user: "dan"}), WithPrefs(&prefStub{})})
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, withCookie("/inbox"))
	body := rec.Body.String()
	for _, want := range []string{
		`class="attn-rows list-card"`,
		`class="attn-row unread"`,
		`data-seq="1"`,
		// Through the marking route, carrying its destination: opening a
		// row is reading it. The live toast, above, still links straight
		// out — a toast is seen, not opened.
		`class="attn-reason" href="/inbox/go?id=`,
		`to=%2fr%2ftest%2fforum%2fabc"`,
		`class="attn-summary"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("inbox page missing %q", want)
		}
	}
	// With no notify companion there is one list, so it is not labelled
	// against a second one that does not exist.
	if strings.Contains(body, `class="attn-head"`) {
		t.Error("the single list was headed as though a second one existed")
	}
}

// The stream is opened by the layout for a signed-in reader only, so an
// anonymous request is a mistake or a probe. An empty 200 tells it it
// worked.
func TestMeSSERefusesAnonymous(t *testing.T) {
	r, _ := attentionHandler(t)
	h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})
	rec := httptest.NewRecorder()
	req, err := http.NewRequest("GET", "/me.sse", nil)
	if err != nil {
		t.Fatal(err)
	}
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("anonymous /me.sse answered %d, want 401", rec.Code)
	}
}

// The reason is the one sentence the toast and the inbox row share, so
// the two can never describe the same event differently.
func TestAttentionReasonNamesTheWorkflow(t *testing.T) {
	for _, tc := range []struct {
		e    attentionEvent
		want string
	}{
		{attentionEvent{Kind: "mention", From: "eve", Repo: "alpha"}, "eve mentioned you in alpha"},
		{attentionEvent{Kind: "reply", From: "eve"}, "eve replied to a thread you follow"},
		{attentionEvent{Kind: "landing.accepted", Repo: "alpha"}, "your landing was accepted in alpha"},
		{attentionEvent{Kind: "mention"}, "somebody mentioned you"},
	} {
		if got := attentionReason(tc.e); got != tc.want {
			t.Errorf("attentionReason(%+v) = %q, want %q", tc.e, got, tc.want)
		}
	}
}

// inboxHTML renders the inbox page's content for one filter over one
// set of rows, the way the handler does: tabs from every row, listing
// from the ones the filter keeps.
func inboxHTML(t *testing.T, f string, rows []attnRow) string {
	t.Helper()
	f = inboxFilter(f)
	data := pageData{Title: "inbox", HasPrefs: true, AttnUnread: 1,
		AttnFilter: f, AttnTabs: attnFilters(rows, 0, f), AttnEmpty: inboxEmptyLine(f)}
	for _, row := range rows {
		if attnMatches(f, row) {
			data.AttnRecent = append(data.AttnRecent, row)
		}
	}
	var b strings.Builder
	if err := noHooks.template(inboxPage).ExecuteTemplate(&b, "content", data); err != nil {
		t.Fatal(err)
	}
	return b.String()
}

func inboxRows() []attnRow {
	return []attnRow{
		{ID: "local:9", Seq: 9, Reason: "alice mentioned you in sqlite", Kind: "mention",
			Repo: "sqlite", At: "2026-08-29T14:05:00Z", Link: "/r/sqlite/chat/4", Unread: true},
		{ID: "msg-7", Reason: "bob replied to a thread you follow", Kind: "reply", Repo: "sqlite",
			Notify: true, At: "2026-08-29T13:00:00Z",
			Link: "/r/sqlite/forum/abcdef123456#p-9", Root: "abcdef123456"},
	}
}

// The bar is links, so a filtered inbox is a URL and the page needs no
// script to narrow itself.
func TestInboxFilterTabsAreLinks(t *testing.T) {
	got := inboxHTML(t, "", inboxRows())
	for _, want := range []string{
		`data-filter="unread"`, `data-filter="all"`, `data-filter="mentions"`,
		`data-filter="replies"`, `data-filter="landings"`,
		`href="/inbox?f=mentions"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the filter bar misses %q:\n%s", want, got)
		}
	}
}

// One row per item: what it is, which sequence it is, and — only when
// it is unread — the dot.
func TestInboxRowCarriesItsHooks(t *testing.T) {
	got := inboxHTML(t, "all", inboxRows())
	for _, want := range []string{
		`data-seq="9"`, `data-reason-kind="mention"`, `data-reason-kind="reply"`,
		`class="attn-dot"`,
		// Done archives THIS row, through the same op the bar uses, and
		// by the id `inbox.list` gave it — a sequence alone cannot
		// address the notify half of the list.
		`action="/inbox/mark"`, `name="id" value="local:9"`, `name="id" value="msg-7"`,
		`data-id="msg-7"`, `data-source="notify"`, `name="as" value="done"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the row misses %q:\n%s", want, got)
		}
	}
	if n := strings.Count(got, `class="attn-dot"`); n != 1 {
		t.Errorf("%d unread dots for one unread row, want 1", n)
	}
	// Leaving a thread belongs to the thread, which is the page that
	// knows whether you follow it; a second control here was a second
	// unlabelled square on every reply.
	if strings.Contains(got, "/subscribe") {
		t.Error("FAIL: the inbox row carries a second action")
	}
}

// A filter narrows the listing on the server: the mentions tab shows no
// forum reply, and says so in its own words when it is empty.
func TestInboxFilterNarrowsTheListing(t *testing.T) {
	got := inboxHTML(t, "mentions", inboxRows())
	if !strings.Contains(got, `data-reason-kind="mention"`) {
		t.Errorf("the mentions filter dropped a mention:\n%s", got)
	}
	if strings.Contains(got, `data-reason-kind="reply"`) {
		t.Errorf("the mentions filter kept a reply:\n%s", got)
	}
	empty := inboxHTML(t, "landings", inboxRows())
	if !strings.Contains(empty, inboxEmptyLine("landings")) {
		t.Errorf("an empty filter says nothing about itself:\n%s", empty)
	}
}

// Settings are folded away at the bottom: a person sets them once and
// triages every day.
func TestInboxSettingsAreFoldedAway(t *testing.T) {
	got := inboxHTML(t, "all", inboxRows())
	i := strings.Index(got, `<details class="inbox-settings">`)
	if i < 0 {
		t.Fatalf("the settings are not folded away:\n%s", got)
	}
	for _, want := range []string{`data-pref="toasts"`, `data-pref="notify_desktop"`,
		`href="/me#notifications"`} {
		if j := strings.Index(got, want); j < i {
			t.Errorf("%q is outside the settings fold", want)
		}
	}
	if strings.Index(got, `class="inbox-bar"`) > i {
		t.Error("the filter bar is below the settings")
	}
}

// A forum link names its thread, which is what "unfollow" needs; a link
// to anything else names none.
func TestForumRootIsReadFromTheLink(t *testing.T) {
	if got := attnForumRoot("/r/sqlite/forum/abc123#p-9"); got != "abc123" {
		t.Errorf("forum root is %q, want abc123", got)
	}
	if got := attnForumRoot("/r/sqlite/chat/4"); got != "" {
		t.Errorf("a chat link yielded a thread root %q", got)
	}
}

// The inbox row states its read state as a hook, not as a class a
// stylesheet edit can quietly drop, and its one action says in a word
// what pressing it does.
func TestInboxRowCarriesItsReadState(t *testing.T) {
	got := inboxHTML(t, "", inboxRows())
	if n := strings.Count(got, `data-unread="1">`); n != 1 {
		t.Errorf("%d rows marked unread, want 1:\n%s", n, got)
	}
	if n := strings.Count(got, `data-unread="0">`); n != 1 {
		t.Errorf("%d rows marked read, want 1:\n%s", n, got)
	}
	for _, want := range []string{`class="attn-done"`, ">Archive<", ">Mark read<"} {
		if !strings.Contains(got, want) {
			t.Errorf("the inbox is missing %q", want)
		}
	}
	// The RULES are asserted against the served sheet, not the page:
	// they used to be an inline <style> here, which a strict style-src
	// drops on the floor. A rule present in the HTML and absent from
	// the stylesheet is a rule nobody ever sees.
	for _, want := range []string{
		".attn-done{", ".attn-row:hover{", "var(--sp-", "var(--r-", "var(--t-fast)",
	} {
		if !strings.Contains(sheet, want) {
			t.Errorf("the stylesheet is missing %q", want)
		}
	}
}

// A row with nothing left to do is offered nothing: one of quarry's
// own events, already read, has no archive, so a control there would
// be a button that appears to do nothing. The columns are the page's
// too — the stamp and the control keep their track whatever the words
// say — so a list of rows reads as a list.
func TestInboxOffersNoControlWithNothingToDo(t *testing.T) {
	read := attnRow{ID: "local:4", Seq: 4, Reason: "a landing you opened was accepted",
		Kind: "landing", Repo: "quarry", At: "2026-08-29T09:00:00Z"}
	unread := read
	unread.ID, unread.Seq, unread.Unread = "local:5", 5, true
	if got := inboxHTML(t, "", []attnRow{read}); strings.Contains(got, `class="attn-done"`) {
		t.Error("FAIL: a read local row was given a control with nothing to do")
	}
	if got := inboxHTML(t, "", []attnRow{unread}); !strings.Contains(got, `class="attn-done"`) {
		t.Error("FAIL: an unread row was not offered a way to mark it read")
	}
	for _, want := range []string{"grid-template-columns:var(--sp-3) minmax(0,1fr) 3.2rem 5.3rem", "-webkit-line-clamp:2"} {
		if !strings.Contains(sheet, want) {
			t.Errorf("FAIL: the stylesheet lets content size the row: missing %q", want)
		}
	}
	t.Log("ok: a control appears only where it acts, and the columns are fixed")
}

// A row that exists in BOTH halves of the inbox carries both ids in
// one, joined by a plus. That id has to survive the markable check, or
// clicking the row never marks it read and its own control fails —
// which is what happened, on exactly the rows the inbox deduped.
func TestDedupedRowIDIsMarkable(t *testing.T) {
	for _, id := range []string{"01M2ABCDEF+local:1234", "local:9", "01M2ABCDEF"} {
		if !inboxMarkableID(id) {
			t.Errorf("FAIL: %q is an id the inbox hands out and cannot mark", id)
		}
	}
	for _, bad := range []string{"", "a b", "a/b", "a?b", "a&b"} {
		if inboxMarkableID(bad) {
			t.Errorf("FAIL: %q was accepted as an id", bad)
		}
	}
	got := inboxHTML(t, "all", []attnRow{{ID: "01M2ABCDEF+local:1234", Seq: 1234, Notify: true,
		Unread: true, Kind: "landing", Repo: "quarry", Reason: "a landing was accepted",
		At: "2026-09-12T09:00:00Z", Link: "/r/quarry/landing/abc"}})
	// html/template writes the plus as &#43; in an attribute, which the
	// browser hands back verbatim; what matters is that it is there.
	if !strings.Contains(got, "01M2ABCDEF&#43;local:1234") {
		t.Errorf("FAIL: the row does not carry the deduped id:\n%s", got)
	}
	if !strings.Contains(got, "id=01M2ABCDEF%2blocal%3a1234") {
		t.Errorf("FAIL: opening the row does not carry its id:\n%s", got)
	}
	t.Log("ok: a deduped row can be opened and marked like any other")
}

// A fault notice lands on the fault it names. The panel's row for a
// signature is an anchor, and the notice quotes it.
func TestExceptionRowIsAnAnchor(t *testing.T) {
	row := errlogRow{Message: "the store answered too slowly", Surface: "web"}
	var buf strings.Builder
	if err := noHooks.template(exceptionsPage).ExecuteTemplate(&buf, "content", pageData{
		ServerErrors: []errlogRow{row}, ErrorTotal: 1, ErrorDistinct: 1}); err != nil {
		t.Fatal(err)
	}
	html := buf.String()
	if want := `id="exc-` + row.Sig() + `"`; !strings.Contains(html, want) {
		t.Errorf("FAIL: the exception row is not an anchor: missing %s", want)
	}
	if !strings.Contains(sheet, ".exc-item:target{") {
		t.Error("FAIL: a targeted exception row is not marked out")
	}
	t.Log("ok: a fault notice can name the row the panel draws for it")
}