Artifact 2902c0b9592bbce66aa176b7f87e653425ac30507f6f41c39a39b4e65bf62162:
- File
internal/web/chatui_test.go
— part of check-in
[eb4793ee8e]
at
2026-08-31 15:49:44
on branch trunk
— serve the stylesheet instead of inlining it in every page
112,118 bytes of <style> rode on every response — 40% of a 309,584-byte timeline page, byte-identical each time and uncacheable because it had no URL, while the JS assets already carried Cache-Control. Now one hashed asset served immutable: a rule change mints a new URL, so a stale cache is impossible without anyone revalidating. (user: quarry, size: 16975) [annotate] [blame] [check-ins using]
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-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) { 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()) } } // --- 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) } css := chatContent(t, pageData{Repo: "alpha"}) if !strings.Contains(css, ".chat-toolbar{") { t.Error("the chat style has no toolbar rule") } if !strings.Contains(css, "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"`, `.composer{`, "field-sizing:content", `form="chat-read"`, `id="chat-read"`, } { if !strings.Contains(got, want) { t.Errorf("the composer 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. func TestChatStyleUsesTheScale(t *testing.T) { got := chatContent(t, pageData{Repo: "alpha"}) for _, want := range []string{"var(--sp-", "var(--r-", "var(--t-fast)", ".chat-date{"} { if !strings.Contains(got, want) { t.Errorf("the chat style 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) } } }