Skip to content
quarry Artifact [6ef13545ed]
Login

Artifact [6ef13545ed]

Artifact 6ef13545ed18dbc8d2951a67c7ae0bf4f64072af0f6d9a347e08415629b94712:


package web

import (
	"html/template"
	"net/http/httptest"
	"strings"

	"golang.org/x/net/html"
	"testing"
)

// chatRun renders a list the way the page does — grouping first, then
// one row at a time through the single renderer.
func chatRun(msgs []ChatMessage, readSeq uint64, viewer string) string {
	return strings.Join(chatRows(msgs, readSeq, viewer), "")
}

// chatRows is the same, one string per row, for the assertions that
// care which row drew what.
func chatRows(msgs []ChatMessage, readSeq uint64, viewer string) []string {
	chatGroupRuns(msgs, readSeq)
	chatRefQuotes(msgs)
	out := make([]string, len(msgs))
	for i, m := range msgs {
		out[i] = string(chatMsgHTML(m, "alpha", viewer, false))
	}
	return out
}

// One header per run of an author's messages inside one minute, and one
// action row per message however it was drawn.
func TestChatGroupsARunUnderOneHeader(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "one", At: "2026-08-29T14:05:01Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "two", At: "2026-08-29T14:05:40Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "three", At: "2026-08-29T14:05:59Z"},
	}, 0, "dan")
	if n := strings.Count(got, `class="chat-head"`); n != 1 {
		t.Errorf("a three-message run drew %d headers, want 1", n)
	}
	if n := strings.Count(got, `class="chat-toolbar"`); n != 3 {
		t.Errorf("%d action rows for three messages, want 3", n)
	}
	if n := strings.Count(got, "data-grouped"); n != 2 {
		t.Errorf("%d rows marked as continuing the run, want 2", n)
	}
}

// A different author, a different minute, and a reply each open a new
// group — the reply because its quote has to sit under a header.
func TestChatBreaksGroups(t *testing.T) {
	for _, tc := range []struct {
		name string
		next ChatMessage
	}{
		{"another author", ChatMessage{Seq: 2, Kind: "msg", User: "eve", Text: "b", At: "2026-08-29T14:05:10Z"}},
		{"another minute", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:06:00Z"}},
		{"a reply", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Ref: 1, RefUser: "eve", RefLine: "x",
			Text: "b", At: "2026-08-29T14:05:10Z"}},
		{"a robot line", ChatMessage{Seq: 2, Kind: "robot", User: "robot", Text: "b", At: "2026-08-29T14:05:10Z"}},
	} {
		got := chatRun([]ChatMessage{
			{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
			tc.next,
		}, 0, "dan")
		if n := strings.Count(got, `class="chat-head"`); n != 2 {
			t.Errorf("%s drew %d headers, want 2", tc.name, n)
		}
	}
}

// The "new" divider is drawn between the read and the unread, so the
// first unread row keeps its own header rather than losing it to a run
// on the other side of the line.
func TestChatFirstUnreadStartsAGroup(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:10Z"},
	}, 1, "dan")
	if n := strings.Count(got, `class="chat-head"`); n != 2 {
		t.Errorf("the first unread row drew %d headers in the run, want 2", n)
	}
}

// The row's controls are one small action row: the permalink, the
// reply, and — on your own message — edit and delete, the edit as a
// disclosure that opens with no script at all.
func TestChatActionsAreOneQuietRow(t *testing.T) {
	m := ChatMessage{Seq: 7, Kind: "msg", User: "dan", Text: "mine", At: "2026-08-29T14:05:00Z"}
	got := string(chatMsgHTML(m, "alpha", "dan", false))
	for _, want := range []string{
		`class="chat-toolbar"`,
		`class="chat-act chatreplyto" href="/r/alpha/chat/7"`,
		`class="chat-act chatpermalink" href="/r/alpha/chat/7">#7<`,
		`<details class="chatedit"><summary class="chat-act">edit</summary>`,
		`<textarea name="text"`,
		`action="/r/alpha/chat/7/delete"`,
		`data-on:submit=`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("own message missing %q:\n%s", want, got)
		}
	}
	// Somebody else's message offers no edit and no delete.
	other := string(chatMsgHTML(m, "alpha", "eve", false))
	for _, unwanted := range []string{"chatedit", "chatdelete"} {
		if strings.Contains(other, unwanted) {
			t.Errorf("another account's message offered %q", unwanted)
		}
	}
	if !strings.Contains(other, `class="chat-toolbar"`) {
		t.Error("a message the viewer cannot edit still needs its permalink row")
	}
	// A deleted message keeps its number and loses its controls.
	del := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
	if strings.Contains(del, "chatedit") || strings.Contains(del, "chatdelete") {
		t.Error("a deleted message still offered edit or delete")
	}
	if !strings.Contains(del, `chatpermalink`) {
		t.Error("a deleted message lost its permalink")
	}
}

// A reply's quote is compact: who, which message, and a bounded first
// line — a reminder, not a second copy.
func TestChatQuoteIsBounded(t *testing.T) {
	long := strings.Repeat("x", 400)
	got := string(chatMsgHTML(ChatMessage{Seq: 9, Kind: "msg", User: "dan", Ref: 4,
		RefUser: "eve", RefLine: long, Text: "answer", At: "2026-08-29T14:05:00Z"},
		"alpha", "dan", false))
	if !strings.Contains(got, `class="chat-quote"`) || !strings.Contains(got, "· #4 ·") {
		t.Errorf("the quote does not name the message it answers:\n%s", got)
	}
	if strings.Contains(got, strings.Repeat("x", 200)) {
		t.Error("the quote carried the whole quoted message")
	}
	if !strings.Contains(got, "…") {
		t.Error("the truncated quote does not say it was cut")
	}
}

// The chat page carries the chip rule the mention markup renders into,
// and the mention itself is visual only — no preview hook, because the
// person card is capability-gated.
func TestChatPageStylesTheMentionChip(t *testing.T) {
	// Asserted against the SERVED sheet, not the page. These rules used
	// to ride in an inline <style> on the chat page, and this test
	// passed for as long as they did — while the browser applied none
	// of them, because a strict style-src drops a <style> element.
	// Rendering the rule into the HTML proves nothing; being in the
	// stylesheet the page links is the only thing that reaches anyone.
	if !strings.Contains(sheet, ".at{") {
		t.Error("the stylesheet has no rule for the mention chip")
	}
}

// The rail and the identicon are the forum's own vocabulary at chat
// density: a group block says whose column it is before a word is read.
func TestChatGroupBlockCarriesTheAuthorsHue(t *testing.T) {
	got := chatRun([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
	}, 0, "dan")
	if !strings.Contains(got, `style="--uh:`) {
		t.Errorf("the group block carries no author hue:\n%s", got)
	}
	if !strings.Contains(got, `class="idn"`) {
		t.Errorf("the group header carries no identicon:\n%s", got)
	}
	// A robot line is the channel talking, not a person: a rail, but no
	// hue to claim it belongs to somebody.
	bot := chatRun([]ChatMessage{
		{Seq: 1, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:00Z"},
	}, 0, "dan")
	if strings.Contains(bot, `style="--uh:`) {
		t.Errorf("a robot line took an author hue:\n%s", bot)
	}
}

// quotesIn counts the diminished quote blocks in one rendered row.
func quotesIn(s string) int { return strings.Count(s, `class="chat-quote"`) }

// An inline #seq is a reference like any other, and gets the same
// diminished quote a formal reply does.
func TestInlineRefRendersItsQuote(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
			At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[1]); n != 1 {
		t.Errorf("an inline #1 drew %d quotes, want 1:\n%s", n, rows[1])
	}
	if !strings.Contains(rows[1], `href="/r/alpha/chat/1"`) {
		t.Errorf("the inline quote does not link the permalink:\n%s", rows[1])
	}
	if !strings.Contains(rows[1], "the original") {
		t.Errorf("the inline quote carries no first line:\n%s", rows[1])
	}
}

// A reply that also names the message it answers is still one quote:
// the formal reply already drew it.
func TestInlineRefIsNotQuotedTwice(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "yes, #1", Ref: 1, RefUser: "eve",
			RefLine: "the original", Refs: []string{"#1"}, At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[1]); n != 1 {
		t.Errorf("a reply that also names its target drew %d quotes, want 1:\n%s", n, rows[1])
	}
}

// Two is the cap: a quote is a reminder, and a message that names five
// things must not become a wall of other people's words.
func TestInlineRefQuotesAreCappedAtTwo(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "one", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "eve", Text: "two", At: "2026-08-29T14:05:10Z"},
		{Seq: 3, Kind: "msg", User: "eve", Text: "three", At: "2026-08-29T14:05:20Z"},
		{Seq: 4, Kind: "msg", User: "dan", Text: "#1 #2 #3",
			Refs: []string{"#1", "#2", "#3"}, At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[3]); n != 2 {
		t.Errorf("three inline refs drew %d quotes, want 2:\n%s", n, rows[3])
	}
}

// A seq the page cannot source is quoted not at all — an aged-out
// reference keeps its link and loses only the words.
func TestUnknownInlineRefRendersNoQuote(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #99", Refs: []string{"#99"},
			At: "2026-08-29T14:06:00Z"},
	}, 0, "dan")
	if n := quotesIn(rows[0]); n != 0 {
		t.Errorf("an unknown seq drew %d quotes, want 0:\n%s", n, rows[0])
	}
}

// The mark-read control is one word-pair on one line: a narrow viewport
// wraps the note beside it, never the control itself.
func TestMarkReadDoesNotWrap(t *testing.T) {
	var b strings.Builder
	if err := chatPage.ExecuteTemplate(&b, "content", pageData{Repo: "alpha"}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(sheet, ".chat-mark{") || !strings.Contains(sheet, "white-space:nowrap") {
		t.Error("the mark-read control may wrap onto two lines")
	}
}

// The permalink page draws the same rows as the chat page, so it needs
// the same rules — one definition, used twice.
func TestPermalinkPageCarriesTheChatStyle(t *testing.T) {
	var b strings.Builder
	if err := chatPermalinkPage.ExecuteTemplate(&b, "content", pageData{Repo: "alpha",
		ChatOne: ChatMessage{Seq: 1, Kind: "msg", User: "dan", Text: "a"}}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(sheet, ".chat-quote{") {
		t.Error("the stylesheet has no chat rules")
	}
}

// fakeLive replays a fixed script down the tail, then closes it, which
// is how liveChatAs is made to return.
type fakeLive struct{ msgs []ChatMessage }

func (f fakeLive) Tail(string, uint64) (<-chan ChatMessage, func()) {
	ch := make(chan ChatMessage, len(f.msgs))
	for _, m := range f.msgs {
		ch <- m
	}
	close(ch)
	return ch, func() {}
}

// The live path groups against every row it appended, not only against
// the messages: a robot line between two of an author's messages breaks
// the run there exactly as it does on the reloaded page.
func TestLiveGroupingBreaksOnARobotLine(t *testing.T) {
	live := fakeLive{msgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:10Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
	}}
	w := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
	liveChatAs(live, "alpha", "dan", false, nil, w, req)
	got := w.Body.String()
	if n := strings.Count(got, `class="chat-head"`); n != 3 {
		t.Errorf("the live run drew %d headers for three rows, want 3:\n%s", n, got)
	}
	if strings.Contains(got, "data-grouped") {
		t.Errorf("a robot line did not break the live run:\n%s", got)
	}
}

// A live row quotes an inline reference to a message the same
// connection already appended, through the same renderer the page uses.
func TestLiveRowQuotesAnInlineRef(t *testing.T) {
	live := fakeLive{msgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
			At: "2026-08-29T14:06:00Z"},
	}}
	w := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
	liveChatAs(live, "alpha", "dan", false, nil, w, req)
	if n := quotesIn(w.Body.String()); n != 1 {
		t.Errorf("the live row drew %d quotes, want 1:\n%s", n, w.Body.String())
	}
}

// --- visual-polish hooks -------------------------------------------------

// chatContent renders the chat page's content block for the assertions
// that are about the page rather than one row.
func chatContent(t *testing.T, d pageData) string {
	t.Helper()
	var b strings.Builder
	if err := chatPage.ExecuteTemplate(&b, "content", d); err != nil {
		t.Fatal(err)
	}
	return b.String()
}

// Every row reserves the same gutter: a group header fills it with the
// author's mark, a continuation row with the absolute clock, so the
// column reads as one column whichever kind of row is in it.
func TestChatRowsShareAnAvatarGutter(t *testing.T) {
	rows := chatRows([]ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
	}, 0, "dan")
	for i, r := range rows {
		if !strings.Contains(r, `class="chat-gut"`) {
			t.Errorf("row %d has no gutter:\n%s", i, r)
		}
	}
	if !strings.Contains(rows[0], `class="idn"`) {
		t.Errorf("the group header's gutter has no mark:\n%s", rows[0])
	}
	if !strings.Contains(rows[1], `class="chat-clock"`) {
		t.Errorf("a continuation row carries no absolute clock:\n%s", rows[1])
	}
	if !strings.Contains(rows[1], `>14:05<`) {
		t.Errorf("the continuation clock is not the message's own time:\n%s", rows[1])
	}
}

// The row's controls are a toolbar, not a line of prose under the text.
func TestChatToolbarIsItsOwnBox(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
		Text: "mine", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
	if !strings.Contains(got, `class="chat-toolbar"`) {
		t.Errorf("no toolbar on the row:\n%s", got)
	}
	if !strings.Contains(sheet, ".chat-toolbar{") {
		t.Error("the stylesheet has no toolbar rule")
	}
	if !strings.Contains(sheet, "var(--shadow-1)") {
		t.Error("the floating toolbar has no shadow token")
	}
}

// The quote is a card: a mark, a name, the number, one line — one row.
func TestChatQuoteCarriesAMark(t *testing.T) {
	got := string(chatQuoteBlock(4, "eve", "the original", "alpha"))
	if !strings.Contains(got, `class="idn"`) {
		t.Errorf("the quote card has no identicon:\n%s", got)
	}
	if !strings.Contains(got, "· #4 ·") {
		t.Errorf("the quote card does not name the message:\n%s", got)
	}
}

// A day boundary gets its own separator, so a log read the next morning
// says which morning.
func TestChatLogSeparatesDays(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatMsgs: []ChatMessage{
		{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-28T14:05:00Z"},
		{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-28T14:06:00Z"},
		{Seq: 3, Kind: "msg", User: "dan", Text: "c", At: "2026-08-29T09:00:00Z"},
	}})
	if n := strings.Count(got, `class="chat-date"`); n != 2 {
		t.Errorf("a two-day log drew %d date separators, want 2:\n%s", n, got)
	}
}

// The composer is one rounded field with its own toolbar strip: the
// note, the key hint, mark-read and Send, inside the border.
func TestChatComposerIsARoundedField(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	for _, want := range []string{
		`class="composer"`,
		`class="composer-bar"`,
		`class="composer-send"`,
		`placeholder="Message #alpha"`,
		`form="chat-read"`,
		`id="chat-read"`,
	} {
		if !strings.Contains(got, want) {
			t.Errorf("the composer is missing %q", want)
		}
	}
	// Its rules come from the served sheet, which is the only place a
	// browser reads them from.
	for _, want := range []string{".composer{", "field-sizing:content"} {
		if !strings.Contains(sheet, want) {
			t.Errorf("the stylesheet is missing %q", want)
		}
	}
	// Signed out there is no composer at all, and nothing dangling.
	out := chatContent(t, pageData{Repo: "alpha"})
	if strings.Contains(out, `class="composer"`) {
		t.Error("a signed-out reader was given a composer")
	}
}

// The chat rules are written in the shared scale, not in loose pixels —
// and they are in the SHEET, which is what a browser applies.
func TestChatStyleUsesTheScale(t *testing.T) {
	for _, want := range []string{"var(--sp-", "var(--r-", "var(--t-fast)", ".chat-date{"} {
		if !strings.Contains(sheet, want) {
			t.Errorf("the stylesheet does not use %q", want)
		}
	}
}

// The scale itself is declared once, in the base stylesheet.
func TestBaseStylesheetDeclaresTheScale(t *testing.T) {
	for _, want := range []string{
		"--sp-1:", "--sp-2:", "--sp-3:", "--sp-4:", "--sp-5:", "--sp-6:",
		"--r-1:", "--r-2:", "--r-3:", "--r-4:", "--shadow-1:", "--t-fast:",
	} {
		if !strings.Contains(styleSource, want) {
			t.Errorf("the base stylesheet declares no %s", want)
		}
	}
}

// The log has an end that can be linked to, and the page offers the
// link. Without script this anchor IS jump-to-latest, which is why it
// is a real <a href> and not a scripted button.
func TestChatOffersJumpToLatestWithoutScript(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
		ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
	if !strings.Contains(got, `id="chat-latest"`) {
		t.Error("the log has no end sentinel to jump to")
	}
	if !strings.Contains(got, `href="#chat-latest"`) {
		t.Error("no jump-to-latest anchor on the live view")
	}
	// The sentinel must be the LAST thing in the log, or it stops
	// naming the latest message. Live rows are inserted before it.
	if strings.Index(got, `id="chat-latest"`) < strings.Index(got, ">hi<") {
		t.Error("the end sentinel is not after the messages")
	}
}

// Following the log is CONDITIONAL. A chat that always scrolls to the
// newest message pulls the page out from under somebody reading back.
func TestChatFollowsOnlyWhenAtTheEnd(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
	if !strings.Contains(got, "$chatEnd") {
		t.Error("nothing tracks whether the reader is at the end")
	}
	if !strings.Contains(got, "data-effect") || !strings.Contains(got, "$chatTick") {
		t.Error("no effect watching for an arrival")
	}
	// Away from the end the arrival is counted and offered, not forced.
	if !strings.Contains(got, "$chatNew") {
		t.Error("an arrival away from the end is not counted")
	}
	// And the page opens where the reading is.
	if !strings.Contains(got, "chat-divider") || !strings.Contains(got, "scrollIntoView") {
		t.Error("the log does not open at the divider or the end")
	}
}

// Delete takes two deliberate actions WITH OR WITHOUT script. The
// confirm() used to be the only guard, and it is Datastar-only — so the
// scriptless path, the one where a slip cannot be undone, had none.
func TestChatDeleteIsGuardedWithoutScript(t *testing.T) {
	got := string(chatMsgHTML(ChatMessage{Seq: 4, Kind: "msg", User: "dan",
		Text: "mine", HTML: "mine"}, "alpha", "dan", false))
	if !strings.Contains(got, `<details class="chatdelete">`) {
		t.Error("delete is not behind a native disclosure")
	}
	if !strings.Contains(got, "<summary") {
		t.Error("the disclosure has no summary to open it")
	}
	// The form is inside the disclosure, so it cannot be submitted
	// until the reader has opened it.
	d := strings.Index(got, `<details class="chatdelete">`)
	f := strings.Index(got[d:], "<form")
	if f < 0 {
		t.Fatal("no delete form inside the disclosure")
	}
	if !strings.Contains(got, "confirm(") {
		t.Error("the pointer-user confirm was dropped")
	}
}

// TestEveryDatastarAttributeSurvivesTheParser is a guard against a bug
// with no visible symptom: a template comment written {{- ... -}}
// between two attributes eats the whitespace BETWEEN them, so
//
//	data-indicator="chatSending"data-on:submit__prevent="…"
//
// reaches the browser as ONE attribute whose name happens to end in a
// quote. The page renders, the styles apply, nothing throws — the
// behaviour is simply absent. It cost a live regression in the chat
// composer, where sending silently reverted to a full page reload.
//
// Parsing is what catches it: a string search for the attribute finds
// the glued text just as happily as the correct markup.
func TestEveryDatastarAttributeSurvivesTheParser(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true,
		ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
	doc, err := html.Parse(strings.NewReader(got))
	if err != nil {
		t.Fatal(err)
	}
	var walk func(*html.Node)
	seen := map[string]bool{}
	walk = func(n *html.Node) {
		for _, a := range n.Attr {
			// A well-formed name is letters, digits and the few
			// punctuation marks Datastar uses. A quote in it means two
			// attributes were run together.
			if strings.ContainsAny(a.Key, `"'<>=`) {
				t.Errorf("attribute name %q on <%s> — two attributes ran together, "+
					"almost certainly a {{- comment between them", a.Key, n.Data)
			}
			seen[a.Key] = true
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			walk(c)
		}
	}
	walk(doc)

	// The composer's submit handler is the one this was found by, so it
	// is named outright rather than left to the general scan.
	for _, want := range []string{"data-on:submit__prevent", "data-bind", "data-indicator"} {
		if !seen[want] {
			t.Errorf("the chat page carries no %s attribute the parser can see", want)
		}
	}
}

// The unread-count badge must ship HIDDEN. It reports a live signal, so
// the only honest state for a page whose script has not run — or whose
// script failed — is "nothing arrived"; an empty white pill is a wrong
// answer stated confidently. It showed exactly that on a phone when an
// unrelated expression threw and aborted the attribute walk before
// reaching it.
func TestChatCountBadgeShipsHidden(t *testing.T) {
	got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true})
	i := strings.Index(got, `class="chat-jump-n"`)
	if i < 0 {
		t.Fatal("no count badge on the jump control")
	}
	tag := got[i:]
	if end := strings.Index(tag, ">"); end >= 0 {
		tag = tag[:end]
	}
	if !strings.Contains(tag, " hidden") {
		t.Errorf("the badge is not hidden in the markup: <span %s>", tag)
	}
}

// permaContent renders the permalink page's content block.
func permaContent(t *testing.T, d pageData) string {
	t.Helper()
	var b strings.Builder
	if err := chatPermalinkPage.ExecuteTemplate(&b, "content", d); err != nil {
		t.Fatal(err)
	}
	return b.String()
}

func permaData() pageData {
	msg := func(seq uint64, text string) ChatMessage {
		return ChatMessage{Seq: seq, Kind: "msg", User: "dan", Text: text, HTML: template.HTML(text)}
	}
	return pageData{Repo: "alpha", ChatViewer: "dan", ChatSeq: 8,
		ChatOne:         msg(8, "the linked one"),
		ChatEarlier:     []ChatMessage{msg(6, "before six"), msg(7, "before seven")},
		ChatLater:       []ChatMessage{msg(9, "after nine")},
		ChatMoreEarlier: 6, ChatMoreLater: 9,
		ChatWinBefore: 5, ChatWinAfter: 5}
}

// A permalink exists to answer "what were they talking about". One
// message answers "what does this message say", which is a different
// question and not the one the reader arrived with.
func TestPermalinkShowsContextBothWays(t *testing.T) {
	got := permaContent(t, permaData())
	for _, want := range []string{"before six", "before seven", "the linked one", "after nine"} {
		if !strings.Contains(got, want) {
			t.Errorf("the context window is missing %q", want)
		}
	}
	// Order is the channel's order — a log, not a message with a list
	// of neighbours bolted either side of it.
	if !(strings.Index(got, "before six") < strings.Index(got, "the linked one") &&
		strings.Index(got, "the linked one") < strings.Index(got, "after nine")) {
		t.Error("the window is not in sequence order")
	}
}

// With context on both sides, position stops saying which message the
// reader followed, so the anchor has to say it itself.
func TestPermalinkMarksTheLinkedMessage(t *testing.T) {
	got := permaContent(t, permaData())
	if !strings.Contains(got, `class="chat-anchor"`) {
		t.Error("the linked message carries no anchor marker")
	}
	if i := strings.Index(got, `class="chat-anchor"`); i < 0 ||
		strings.Index(got, "the linked one") < i {
		t.Error("the anchor marker is not on the linked message")
	}
}

// Growing the window is a LINK, so it works with no script, and it
// re-anchors on the linked message so the page does not jump.
func TestPermalinkGrowsTheWindowWithLinks(t *testing.T) {
	got := permaContent(t, permaData())
	if !strings.Contains(got, "before=20&amp;after=5") {
		t.Errorf("no 'earlier' link that adds a step to the window:\n%s", got)
	}
	if !strings.Contains(got, "before=5&amp;after=20") {
		t.Error("no 'later' link that adds a step to the window")
	}
	if strings.Count(got, "#chatmsg-8") < 2 {
		t.Error("the grow links do not re-anchor on the linked message")
	}

	// At the ends of the channel there is nothing to offer, and a
	// control that leads nowhere teaches nothing.
	d := permaData()
	d.ChatMoreEarlier, d.ChatMoreLater = 0, 0
	if got := permaContent(t, d); strings.Contains(got, "chat-more") {
		t.Error("a grow control is drawn at the end of the channel")
	}
}

// Following a reference and wanting to answer it should not mean going
// back to the channel and finding the message again.
func TestPermalinkRepliesToTheLinkedMessage(t *testing.T) {
	got := permaContent(t, permaData())
	if !strings.Contains(got, `name="ref" value="8"`) {
		t.Error("the composer does not carry the message it is replying to")
	}
	if !strings.Contains(got, `name="back"`) {
		t.Error("a reply would not return to the page it was written on")
	}
	// It is a PLAIN post here: there is no stream on this page to
	// deliver the message back, so the redirect is the feedback.
	if strings.Contains(got, "$chatEnd = true") {
		t.Error("the permalink composer follows a log it does not have")
	}
	if strings.Contains(got, `form="chat-read"`) {
		t.Error("the permalink offers mark-read, which belongs to the channel")
	}

	d := permaData()
	d.ChatViewer = ""
	if got := permaContent(t, d); strings.Contains(got, `class="composer"`) {
		t.Error("a signed-out reader is offered a composer")
	}
}

// The page names the channel. A stream sequence is an implementation
// detail; nobody follows a link from a ticket in order to visit "#8".
func TestPermalinkNamesTheChannel(t *testing.T) {
	got := permaContent(t, permaData())
	if strings.Contains(got, "<h1") {
		t.Error("the page draws its own h1 beside the layout's")
	}
	if !strings.Contains(got, "Linked message <b>#8</b>, in context") {
		t.Error("the page does not say which message was linked")
	}
	if !strings.Contains(got, `href="/r/alpha/chat?at=8"`) {
		t.Error("no way into the channel positioned at this message")
	}
}

// Aged out is honest; a dead end is not. The page still says where the
// reader can go and why the message is gone.
func TestPermalinkExpiredStillOffersTheChannel(t *testing.T) {
	got := permaContent(t, pageData{Repo: "alpha", ChatSeq: 8, ChatExpired: true})
	if !strings.Contains(got, `data-hook="chat-expired"`) {
		t.Error("no expired state")
	}
	if !strings.Contains(got, `href="/r/alpha/chat"`) {
		t.Error("the expired page is a dead end")
	}
	if strings.Contains(got, "chat-anchor") {
		t.Error("the expired page draws a message it does not have")
	}
}

// Replies that fell outside the window are named as being elsewhere,
// rather than shown as if they were adjacent or dropped in silence.
func TestPermalinkNamesRepliesOutsideTheWindow(t *testing.T) {
	d := permaData()
	d.ChatAway = []ChatMessage{{Seq: 400, Kind: "msg", User: "eve", Text: "much later", HTML: template.HTML("much later")}}
	got := permaContent(t, d)
	if !strings.Contains(got, "Replies elsewhere") || !strings.Contains(got, "much later") {
		t.Error("a distant reply is neither shown nor named")
	}
	if got := permaContent(t, permaData()); strings.Contains(got, "Replies elsewhere") {
		t.Error("the section is drawn with nothing in it")
	}
}

// "Open in the channel at this message" cannot be a fragment: the live
// view loads the newest page, so an older message's id is usually not
// on the page to scroll to. The server positions and marks it.
func TestChatOpensAtAMessage(t *testing.T) {
	d := pageData{Repo: "alpha", ChatViewer: "dan", ChatAt: 8,
		ChatMsgs: []ChatMessage{
			{Seq: 7, Kind: "msg", User: "dan", Text: "seven", HTML: template.HTML("seven")},
			{Seq: 8, Kind: "msg", User: "dan", Text: "eight", HTML: template.HTML("eight")}}}
	got := chatContent(t, d)
	if !strings.Contains(got, `data-seq=&quot;8&quot;`) {
		t.Errorf("the log does not open at the named message:\n%s", got[:min(len(got), 900)])
	}
	if !strings.Contains(got, `<li class="chat-anchor">`) {
		t.Error("the message opened at is not marked")
	}
	// Without ?at the log still opens at the unread divider.
	d.ChatAt = 0
	if got := chatContent(t, d); !strings.Contains(got, ".chat-divider") ||
		strings.Contains(got, `<li class="chat-anchor">`) {
		t.Error("an ordinary visit no longer opens at the divider")
	}
}

// One control, one shape. The older-page jump is a plain link and the
// live one is signal-driven, but both are the same caret in the same
// 2rem circle — drawing the older one as a sentence left the words
// spilling out of the circle and across the composer.
func TestJumpToLatestIsTheSameShapeOnBothPaths(t *testing.T) {
	older := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", ChatBefore: 40})
	if !strings.Contains(older, `class="chat-jump"`) {
		t.Fatal("an older page offers no way back to the latest")
	}
	if strings.Contains(older, "jump to latest &darr;") || strings.Contains(older, ">jump to latest<") {
		t.Error("the older-page jump is still drawn as words inside the circle")
	}
	if !strings.Contains(older, "#i-caret-down") {
		t.Error("the older-page jump carries no caret")
	}
	// The words survive as the accessible name, which is where a glyph
	// control's label belongs.
	if !strings.Contains(older, `aria-label="jump to the latest message"`) {
		t.Error("the older-page jump has no accessible name")
	}
}

// Reply, edit and delete must be findable with a mouse. They used to be
// a permanent row on a touch device and invisible until hover on a
// desktop — the same message offering different products depending on
// what you were holding.
func TestMessageActionsAreVisibleWithoutHovering(t *testing.T) {
	i := strings.Index(sheet, "@media (hover: hover) and (pointer: fine){")
	if i < 0 {
		t.Fatal("no pointer query in the sheet")
	}
	block := sheet[i : i+400]
	if strings.Contains(block, "opacity:0;") || strings.Contains(block, "opacity:0}") {
		t.Error("the actions are still fully hidden at rest where there is a pointer")
	}
	if strings.Contains(block, "pointer-events:none") {
		t.Error("the actions are still unclickable at rest where there is a pointer")
	}
}