package web
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/theme"
)
// /me's Appearance form stores the two choices as cookies, clears one
// set back to quarry's own, and refuses an id it does not know rather
// than storing something no page can serve.
func TestAppearanceSavesBothThemes(t *testing.T) {
mux := http.NewServeMux()
registerAppearance(mux)
post := func(site, code string) *httptest.ResponseRecorder {
form := url.Values{"site": {site}, "code": {code}}
req := httptest.NewRequest("POST", "/me/appearance", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
rec := post("gruvbox-dark-hard", "catppuccin-latte")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/me#appearance" {
t.Fatalf("FAIL: save answered %d %q", rec.Code, rec.Header().Get("Location"))
}
got := map[string]*http.Cookie{}
for _, c := range rec.Result().Cookies() {
got[c.Name] = c
}
if c := got[siteThemeCookie]; c == nil || c.Value != "gruvbox-dark-hard" || c.MaxAge <= 0 {
t.Errorf("FAIL: site theme cookie %+v", c)
}
if c := got[codeThemeCookie]; c == nil || c.Value != "catppuccin-latte" || c.MaxAge <= 0 {
t.Errorf("FAIL: code theme cookie %+v", c)
}
rec = post("", "")
for _, c := range rec.Result().Cookies() {
if c.MaxAge >= 0 {
t.Errorf("FAIL: choosing quarry's own palette kept %s=%q", c.Name, c.Value)
}
}
if rec = post("no-such-theme", ""); rec.Code != http.StatusBadRequest {
t.Errorf("FAIL: an unknown theme answered %d", rec.Code)
}
}
// Each theme's sheet is served at its hashed path, as CSS, cacheable
// for good, because a regenerated theme is a new path.
func TestThemeSheetsAreServed(t *testing.T) {
mux := http.NewServeMux()
registerAppearance(mux)
th, _ := theme.ByID("rose-pine-dawn")
for _, u := range []string{theme.SiteURL(th), theme.CodeURL(th)} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", u, nil))
body, _ := io.ReadAll(rec.Body)
if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") ||
!strings.Contains(rec.Header().Get("Cache-Control"), "immutable") || !bytes.Contains(body, []byte("--syn-kw:")) {
t.Errorf("FAIL: %s answered %d %q %q", u, rec.Code, rec.Header().Get("Content-Type"), body)
}
}
}
// A reader's choices reach every page: both sheets are linked after
// quarry's own, under the ids /me's preview swaps, and the menu names
// the site theme instead of offering a light/dark cycle it overrides.
func TestChosenThemesReachEveryPage(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: siteThemeCookie, Value: "nord"})
req.AddCookie(&http.Cookie{Name: codeThemeCookie, Value: "github-light-default"})
data := pageData{Title: "Home", SignedIn: "dmestas", SheetURL: sheetURL}
readAppearance(req, &data)
var b bytes.Buffer
if err := noHooks.template(explorePage).Execute(&b, data); err != nil {
t.Fatal(err)
}
page := b.String()
nord, _ := theme.ByID("nord")
gh, _ := theme.ByID("github-light-default")
sheet := strings.Index(page, sheetURL)
site := strings.Index(page, `href="`+theme.SiteURL(nord)+`" id="site-theme"`)
code := strings.Index(page, `href="`+theme.CodeURL(gh)+`" id="code-theme"`)
if sheet < 0 || site < sheet || code < sheet {
t.Errorf("FAIL: theme sheets not linked after quarry's (sheet %d, site %d, code %d)", sheet, site, code)
}
if !strings.Contains(page, `data-hook="theme-chosen"`) || strings.Contains(page, `action="/theme"`) {
t.Error("FAIL: the menu still offers the light/dark cycle under a chosen site theme")
}
// An id this build does not know is no choice at all.
req = httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: siteThemeCookie, Value: "gone-theme"})
data = pageData{Title: "Home"}
readAppearance(req, &data)
if data.SiteThemeURL != "" || data.SiteTheme != "" {
t.Errorf("FAIL: an unknown site theme was linked: %+v", data.SiteThemeURL)
}
}
// /me lists every theme in both lists, grouped dark and light, marks
// the saved ones, and draws a sample of code and a diff to watch.
func TestMeOffersBothThemeLists(t *testing.T) {
req := httptest.NewRequest("GET", "/me", nil)
req.AddCookie(&http.Cookie{Name: codeThemeCookie, Value: "kanagawa-wave"})
me := meView{Name: "jva", Notify: &prefsView{}}
var b strings.Builder
if err := noHooks.template(mePageTmpl).ExecuteTemplate(&b, "content",
pageData{Title: "jva", User: "jva", Me: &me, Appear: appearanceOf(req)}); err != nil {
t.Fatal(err)
}
got := b.String()
for _, hook := range []string{`data-hook="account-appearance"`, `data-hook="appearance-site"`,
`data-hook="appearance-code"`, `data-hook="appearance-sample"`, `data-hook="appearance-acts"`, `href="#appearance"`} {
if !strings.Contains(got, hook) {
t.Errorf("FAIL: /me lacks %s", hook)
}
}
for _, list := range []string{"ap-site", "ap-code"} {
sel := regexp.MustCompile(`(?s)<select id="` + list + `".*?</select>`).FindString(got)
if n := strings.Count(sel, "data-url="); n != len(theme.All) {
t.Errorf("FAIL: %s offers %d themes, want %d", list, n, len(theme.All))
}
if !strings.Contains(sel, `<optgroup label="Dark">`) || !strings.Contains(sel, `<optgroup label="Light">`) {
t.Errorf("FAIL: %s is not grouped dark and light", list)
}
}
if !regexp.MustCompile(`value="kanagawa-wave" data-url="[^"]+" selected`).MatchString(got) {
t.Error("FAIL: the saved code theme is not selected")
}
if !regexp.MustCompile(`<option value="" selected>Quarry</option>`).MatchString(got) {
t.Error("FAIL: with no site theme, Quarry is not selected")
}
if !strings.Contains(got, `class="d-addition"`) || !strings.Contains(got, `class="d-deletion"`) || !strings.Contains(got, `<pre class="chroma`) {
t.Error("FAIL: the sample lacks code or a diff")
}
}