package web
import (
"net/http/httptest"
"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 {
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-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="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) {
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")
}
}
// 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(b.String(), "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(b.String(), ".chat-quote{") {
t.Error("the permalink page 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())
}
}