package web
import (
"bytes"
"html/template"
"strings"
"testing"
)
func tktFixture() ticketDetail {
t := ticketDetail{ID: "abcdef0123456789abcdef0123456789abcdef01",
Fields: []ticketField{{Name: "title", Value: "T"}, {Name: "status", Value: "Review"},
{Name: "priority", Value: "Urgent"}}}
t.Changes = append(t.Changes, struct {
Date string `json:"date"`
User string `json:"user"`
Fields []ticketField `json:"fields"`
}{Date: "2026-08-01T10:00:00", User: "ann", Fields: []ticketField{{Name: "status", Value: "Open"}, {Name: "comment", Value: "desc"}}})
t.Changes = append(t.Changes, struct {
Date string `json:"date"`
User string `json:"user"`
Fields []ticketField `json:"fields"`
}{Date: "2026-08-02T10:00:00", User: "bob", Fields: []ticketField{{Name: "status", Value: "Review"}, {Name: "priority", Value: "Urgent"}, {Name: "icomment", Value: "x"}}})
return t
}
func renderTkt(t *testing.T, name string, data pageData) string {
t.Helper()
var buf bytes.Buffer
if err := ticketPage.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatal(err)
}
return buf.String()
}
// The sidebar is selects from the vocabulary, and a word the tracker
// already uses but the vocabulary lacks is still offered — selected.
func TestTicketSidebarSelectsKeepForeignWords(t *testing.T) {
vocab := map[string][]string{"status": {"Open", "Review", "Closed"}, "priority": {"High", "Low"}}
tk := tktFixture()
html := renderTkt(t, "tkt-side", pageData{Repo: "q", Ticket: tk, CanWrite: true,
TicketFields: ticketControls(tk, vocab), Vocab: vocab})
if !strings.Contains(html, `<select name="status"`) || !strings.Contains(html, `<option selected>Review</option>`) {
t.Errorf("status select with current value missing:\n%s", html)
}
if !strings.Contains(html, `<option selected>Urgent</option>`) {
t.Errorf("out-of-vocabulary priority was refused:\n%s", html)
}
if strings.Contains(html, `<input name="subsystem"`) == false {
t.Errorf("a field with no vocabulary should stay free text:\n%s", html)
}
if strings.Contains(html, `name="resolution"`) {
t.Errorf("resolution is editable on an open ticket:\n%s", html)
}
}
// Readers and read-only mirrors get facts, not forms.
func TestTicketSidebarReadOnlyHasNoForm(t *testing.T) {
tk := tktFixture()
for _, d := range []pageData{
{Repo: "q", Ticket: tk, TicketFields: ticketControls(tk, nil)},
func() pageData {
d := pageData{Repo: "q", Ticket: tk, CanWrite: true, TicketFields: ticketControls(tk, nil)}
d.Posture.Mirror.Tracking, d.Posture.Mirror.URL = true, "https://x/y"
return d
}(),
} {
if html := renderTkt(t, "tkt-side", d); strings.Contains(html, "<form") {
t.Errorf("form rendered for a non-writer:\n%s", html)
}
}
}
// A field change lands between the remarks it was made between, and
// names where the field came from.
func TestTicketTimelineInterleavesChanges(t *testing.T) {
tk := tktFixture()
ev := ticketTimeline(tk, []ticketComment{
{Date: "2026-08-01T12:00:00", Login: "ann", HTML: template.HTML("<p>first</p>")},
{Date: "2026-08-03T12:00:00", Login: "cid", HTML: template.HTML("<p>later</p>")},
})
if len(ev) != 3 || ev[1].Login != "bob" || len(ev[1].Deltas) != 2 {
t.Fatalf("want comment, change, comment; got %+v", ev)
}
html := renderTkt(t, "tkt-timeline", pageData{Timeline: ev})
if !strings.Contains(html, "status <b>Open → Review</b>") || !strings.Contains(html, "priority <b>Urgent</b>") {
t.Errorf("delta line missing:\n%s", html)
}
}
// Open tickets offer Comment & close with a resolution; closed ones
// offer Reopen.
func TestTicketComposerCloseOrReopen(t *testing.T) {
vocab := map[string][]string{"resolution": {"Fixed", "Rejected"}}
tk := tktFixture()
open := renderTkt(t, "content", pageData{Repo: "q", Ticket: tk, CanWrite: true, TicketFields: ticketControls(tk, vocab)})
if !strings.Contains(open, `value="Closed">Comment & close`) || !strings.Contains(open, `<select name="resolution"`) {
t.Errorf("open ticket lacks close control:\n%s", open)
}
tk.Fields[1].Value = "Closed"
closed := renderTkt(t, "content", pageData{Repo: "q", Ticket: tk, CanWrite: true, TicketFields: ticketControls(tk, vocab)})
if !strings.Contains(closed, `value="Open">Reopen`) || strings.Contains(closed, "Comment & close") {
t.Errorf("closed ticket lacks reopen:\n%s", closed)
}
}
// Rewriting the body or the title is an event too — GitHub's "edited"
// — and a retitle names the new title, not an arrow between two
// paragraphs.
func TestTicketTimelineNamesEdits(t *testing.T) {
tk := tktFixture()
tk.Changes = append(tk.Changes, struct {
Date string `json:"date"`
User string `json:"user"`
Fields []ticketField `json:"fields"`
}{Date: "2026-08-04T10:00:00", User: "dee", Fields: []ticketField{{Name: "comment", Value: "new body"}, {Name: "cmimetype", Value: "text/x-markdown"}, {Name: "title", Value: "Short"}}})
html := renderTkt(t, "tkt-timeline", pageData{Timeline: ticketTimeline(tk, nil)})
if !strings.Contains(html, "dee edited the description and retitled <b>Short</b>") {
t.Errorf("edit event missing:\n%s", html)
}
}