Skip to content
quarry Artifact [2fd1d8549c]
Login

Artifact [2fd1d8549c]

Artifact 2fd1d8549c8a460adca809a8fe6140a9b99e5b86069bb946c28a18b31271fc95:


package web

import (
	"strings"
	"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 {
	chatGroupRuns(msgs, readSeq)
	var b strings.Builder
	for _, m := range msgs {
		b.WriteString(string(chatMsgHTML(m, "alpha", viewer, false)))
	}
	return b.String()
}

// 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-actions"`); 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-actions"`,
		`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-actions"`) {
		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="chatquote"`) || !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) {
	var b strings.Builder
	if err := chatPage.ExecuteTemplate(&b, "content", pageData{Repo: "alpha"}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(b.String(), ".at{") {
		t.Error("the chat page has no rule for the mention chip")
	}
}