Files
go-nc-exapp/navigation_test.go
konradandCursor fad9806585 Hide App navigation on a narrow screen until the user opens it.
A width at or below Nextcloud's 1024px breakpoint keeps the tree off the page so the content stays usable, and the navigation button brings it back.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 18:56:44 +02:00

271 lines
8.3 KiB
Go

package gonexapp_test
import (
"html"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"gitea.neitzel.de/konrad/go-nc-exapp"
)
func sampleNavigation() gonexapp.AppNavigation {
items := []gonexapp.Item{
{
ID: "domains", Label: "Domains",
Children: []gonexapp.Item{{ID: "example.com", Label: "example.com"}},
},
{ID: "keys", Label: "Keys"},
}
return gonexapp.AppNavigation{
Items: func(*http.Request) []gonexapp.Item { return items },
Page: func(_ *http.Request, id string) (string, bool) {
switch id {
case "domains":
return "<p>domains-page</p>", true
case "keys":
return "<p>keys-page</p>", true
case "example.com":
return "<p>zone-page</p>", true
default:
return "", false
}
},
Header: func(*http.Request) string { return `<p id="visit">folder-line</p>` },
DefaultID: "domains",
MissingMessage: "that item is not in this Visit",
}
}
func navBody(t *testing.T, rawURL string) string {
t.Helper()
rec := httptest.NewRecorder()
sampleNavigation().Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, rawURL, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s: got %d", rawURL, rec.Code)
}
return rec.Body.String()
}
func navAddress(t *testing.T, body string) url.Values {
t.Helper()
const key = `data-address="`
_, after, ok := strings.Cut(body, key)
if !ok {
t.Fatalf("no data-address in %s", body)
}
rest := after
end := strings.Index(rest, `"`)
if end < 0 {
t.Fatalf("unclosed data-address")
}
raw, err := url.QueryUnescape(html.UnescapeString(rest[:end]))
if err != nil {
t.Fatal(err)
}
q, err := url.ParseQuery(strings.TrimPrefix(raw, "?"))
if err != nil {
t.Fatal(err)
}
return q
}
func TestAppNavigationShowsSelectedPage(t *testing.T) {
body := navBody(t, "/?item=keys")
if !strings.Contains(body, "<p>keys-page</p>") {
t.Fatalf("got %s", body)
}
if !strings.Contains(body, `data-selected="keys"`) {
t.Fatalf("got %s", body)
}
if navAddress(t, body).Get("item") != "keys" {
t.Fatalf("address: %s", navAddress(t, body).Encode())
}
}
func TestAppNavigationDefaultsWhenItemMissingFromAddress(t *testing.T) {
body := navBody(t, "/")
if !strings.Contains(body, "<p>domains-page</p>") {
t.Fatalf("got %s", body)
}
if navAddress(t, body).Get("item") != "domains" {
t.Fatalf("address item = %q", navAddress(t, body).Get("item"))
}
if strings.Contains(body, "that item is not in this Visit") {
t.Fatalf("default open should not show the missing-item message: %s", body)
}
}
func TestAppNavigationMissingItemSelectsDefault(t *testing.T) {
body := navBody(t, "/?folder=share&item=gone")
if !strings.Contains(body, "<p>domains-page</p>") {
t.Fatalf("got %s", body)
}
if !strings.Contains(body, "that item is not in this Visit") {
t.Fatalf("got %s", body)
}
q := navAddress(t, body)
if q.Get("item") != "domains" || q.Get("folder") != "share" {
t.Fatalf("address = %s", q.Encode())
}
}
func TestAppNavigationPageRefusalSelectsDefault(t *testing.T) {
nav := sampleNavigation()
inner := nav.Page
nav.Page = func(r *http.Request, id string) (string, bool) {
if id == "keys" {
return "", false
}
return inner(r, id)
}
rec := httptest.NewRecorder()
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?folder=share&item=keys", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "<p>domains-page</p>") || !strings.Contains(body, "that item is not in this Visit") {
t.Fatalf("got %s", body)
}
q := navAddress(t, body)
if q.Get("item") != "domains" || q.Get("folder") != "share" {
t.Fatalf("address = %s", q.Encode())
}
}
func TestAppNavigationChildExpandsParents(t *testing.T) {
body := navBody(t, "/?item=example.com")
if !strings.Contains(body, "<p>zone-page</p>") {
t.Fatalf("got %s", body)
}
if !strings.Contains(body, `data-id="domains" data-expanded="true"`) {
t.Fatalf("got %s", body)
}
}
func TestAppNavigationLoadsNextcloudTheme(t *testing.T) {
body := navBody(t, "/")
for _, need := range []string{
"/apps/theming/css/default.css",
"/index.php/apps/theming/theme/default.css",
"/index.php/apps/theming/theme/dark.css",
} {
if !strings.Contains(body, need) {
t.Fatalf("missing %s in %s", need, body)
}
}
}
func TestAppNavigationThemeCanBeDisabled(t *testing.T) {
nav := sampleNavigation()
nav.DisableTheme = true
rec := httptest.NewRecorder()
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if strings.Contains(rec.Body.String(), "/apps/theming/css/default.css") {
t.Fatalf("theme stylesheet still present: %s", rec.Body.String())
}
}
func TestAppNavigationFoldControlFollowsLabel(t *testing.T) {
body := navBody(t, "/?item=domains")
name := strings.Index(body, ">Domains<")
fold := strings.Index(body, `aria-label="Fold Domains"`)
if name < 0 || fold < 0 || fold < name {
t.Fatalf("name %d fold %d", name, fold)
}
}
func TestAppNavigationRendersIconBeforeLabel(t *testing.T) {
nav := sampleNavigation()
nav.Items = func(*http.Request) []gonexapp.Item {
return []gonexapp.Item{{ID: "domains", Label: "Domains", Icon: gonexapp.NextcloudIcons.Folder}}
}
rec := httptest.NewRecorder()
nav.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?item=domains", nil))
body := rec.Body.String()
icon := strings.Index(body, `src="/core/img/filetypes/folder.svg"`)
name := strings.Index(body, ">Domains<")
if icon < 0 || name < 0 || icon > name {
t.Fatalf("icon %d name %d", icon, name)
}
}
func TestAppNavigationHeaderPrecedesTree(t *testing.T) {
body := navBody(t, "/?item=domains")
header := strings.Index(body, "folder-line")
tree := strings.Index(body, ">Domains<")
if header < 0 || tree < 0 || header > tree {
t.Fatalf("header %d tree %d in %s", header, tree, body)
}
}
func TestAppNavigationOffersNarrowScreenToggle(t *testing.T) {
body := navBody(t, "/")
for _, need := range []string{
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`id="nav-toggle"`,
`aria-controls="app-navigation"`,
`aria-expanded="false"`,
`aria-label="Open navigation"`,
`id="nav-backdrop"`,
`id="app-navigation"`,
`max-width: 1024px`,
} {
if !strings.Contains(body, need) {
t.Errorf("missing %s", need)
}
}
}
// navLayoutJS reports how App navigation is laid out: "wide", "closed", or "open".
const navLayoutJS = `(() => {
const nav = document.querySelector("nav.app-navigation");
const btn = document.getElementById("nav-toggle");
const narrow = window.matchMedia("(max-width: 1024px)").matches;
const box = nav.getBoundingClientRect();
const navShown = getComputedStyle(nav).display !== "none" && box.width > 0 && box.left < window.innerWidth && box.right > 0;
const btnShown = getComputedStyle(btn).display !== "none";
const expanded = btn.getAttribute("aria-expanded") === "true";
if (!narrow && navShown && !btnShown && !expanded) return "wide";
if (narrow && !navShown && btnShown && !expanded) return "closed";
if (narrow && navShown && btnShown && expanded) return "open";
return "narrow=" + narrow + " nav=" + navShown + " btn=" + btnShown + " expanded=" + expanded + " width=" + window.innerWidth;
})()`
func TestAppNavigationWideScreenKeepsTheTreeVisible(t *testing.T) {
drive(t, "/nav",
chromedp.EmulateViewport(1280, 800),
waitJS(navLayoutJS, "wide", false),
)
}
func TestAppNavigationNarrowScreenOpensTheTreeOnDemand(t *testing.T) {
drive(t, "/nav",
chromedp.EmulateViewport(390, 800),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.MouseClickXY(370, 400),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.KeyEvent(kb.Escape),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.Click(`a.app-navigation-entry-link[href*="item=keys"]`, chromedp.ByQuery),
waitJS(`document.querySelector("main").textContent`, "keys-page", true),
waitJS(navLayoutJS, "closed", false),
)
}