Files
felhom-controller/controller/internal/web/nav_accordion_test.go
T
admin fd93020f39 test(nav): pin the v0.146.0 accordion's server-side half
The collapsible sidebar is a progressive enhancement: the server marks the group
owning the active page with .is-open, so the correct group is open before any JS
runs and stays open if JS never runs. That server-side half is what these tests
pin — the part a browser screenshot confirms only for whichever page happened to
be open when the screenshot was taken.

Four properties, rendered through the real shared layout rather than a hand-built
fragment:
 - every sub-page opens its own group, with aria-expanded=true and an .active
   toggle, and EXACTLY ONE group open (the count is asserted, not just the group
   we expected);
 - a page outside any group (dashboard) opens nothing;
 - each group's landing page still exists as a sub-link — the property that made
   converting the headers from <a> to <button> safe. If someone drops one of
   those sub-links the destination becomes unreachable from the sidebar
   SILENTLY, because the header still looks clickable;
 - the toggle is a real <button> and its aria-controls targets an element that
   actually exists.

Red-proofed: removing `{{if $storageOpen}} is-open{{end}}` from layout.html
fails both the open-group assertion and the exactly-one-open count on the two
storage pages, then passes again when restored.

Note on verification: the live authenticated render could NOT be checked from
here — the demo controller's password is customer-owned since the claim flow
(Viktor set it during the 2026-07-18 rehearsal), so the credentials on the build
server are stale and a curl-login returns the Bejelentkezés page. These render
tests exercise the same template through the same loadTemplates() path the server
uses; the visual leg needs Viktor's browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-18 20:57:39 +02:00

138 lines
5.3 KiB
Go

package web
import (
"regexp"
"strings"
"testing"
)
// v0.146.0 nav polish. The sidebar's collapsible groups are a PROGRESSIVE ENHANCEMENT: the server
// marks the group owning the active page with .is-open, so the correct group is already open before
// any JS runs and stays open if JS never runs at all. That server-side half is what these tests
// pin — the part a browser screenshot would confirm only for whichever page happened to be open.
//
// Red-proof: drop the `{{if $storageOpen}} is-open{{end}}` (or the matching aria-expanded) from
// layout.html and TestNavGroup_ActiveGroupRendersOpenServerSide fails.
// navGroupRe finds a sidebar group <li> and captures its class list and the id of the group it owns.
var navGroupRe = regexp.MustCompile(`<li class="(nav-group[^"]*)">\s*<button type="button" class="(nav-group-toggle[^"]*)" aria-expanded="(true|false)" aria-controls="(nav-group-[a-z]+)"`)
type navGroup struct {
liClass, btnClass, ariaExpanded, id string
}
func parseNavGroups(t *testing.T, html string) map[string]navGroup {
t.Helper()
out := map[string]navGroup{}
for _, m := range navGroupRe.FindAllStringSubmatch(html, -1) {
out[m[4]] = navGroup{liClass: m[1], btnClass: m[2], ariaExpanded: m[3], id: m[4]}
}
if len(out) == 0 {
t.Fatal("no sidebar nav groups parsed — the accordion markup or this pattern has drifted")
}
return out
}
// renderNavFor renders any page through the shared layout so the sidebar is exercised for real,
// rather than asserting against a hand-built fragment.
func renderNavFor(t *testing.T, page string) string {
t.Helper()
return renderBackupPage(t, "backups", map[string]interface{}{
"Page": page, "Title": "teszt",
"Backup": nil,
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
})
}
func TestNavGroup_ActiveGroupRendersOpenServerSide(t *testing.T) {
// Every sub-page, mapped to the group that must be open when it is the active page.
cases := map[string]string{
"storage": "nav-group-storage",
"storage-network": "nav-group-storage",
"backups": "nav-group-backups",
"backups-remote": "nav-group-backups",
"backups-apps": "nav-group-backups",
"backups-restore": "nav-group-backups",
"sharing": "nav-group-sharing",
}
for page, wantOpen := range cases {
groups := parseNavGroups(t, renderNavFor(t, page))
openCount := 0
for id, g := range groups {
isOpen := strings.Contains(g.liClass, "is-open")
if isOpen {
openCount++
}
if id == wantOpen {
if !isOpen {
t.Errorf("page %q: group %s should render open, class was %q", page, id, g.liClass)
}
if g.ariaExpanded != "true" {
t.Errorf("page %q: group %s open but aria-expanded=%q", page, id, g.ariaExpanded)
}
// The header must also read as active, or the open group looks unselected.
if !strings.Contains(g.btnClass, "active") {
t.Errorf("page %q: group %s open but its toggle is not .active (%q)", page, id, g.btnClass)
}
} else {
if isOpen {
t.Errorf("page %q: group %s should be closed, class was %q", page, id, g.liClass)
}
if g.ariaExpanded != "false" {
t.Errorf("page %q: group %s closed but aria-expanded=%q", page, id, g.ariaExpanded)
}
}
}
// "Exactly one open" is the whole contract — assert the count, not just the one we expected.
if openCount != 1 {
t.Errorf("page %q: %d groups rendered open, want exactly 1", page, openCount)
}
}
}
func TestNavGroup_PageOutsideAnyGroupOpensNothing(t *testing.T) {
// A top-level page (Vezérlőpult) belongs to no group; nothing should be forced open.
groups := parseNavGroups(t, renderNavFor(t, "dashboard"))
for id, g := range groups {
if strings.Contains(g.liClass, "is-open") {
t.Errorf("dashboard: group %s rendered open, want all closed", id)
}
if g.ariaExpanded != "false" {
t.Errorf("dashboard: group %s aria-expanded=%q, want false", id, g.ariaExpanded)
}
}
}
func TestNavGroup_EveryGroupLandingPageSurvivesAsASubItem(t *testing.T) {
// The group headers stopped being links in v0.146.0, which is only safe because each group's own
// landing page is ALSO its first sub-item. If someone removes one of those sub-links, that
// destination becomes unreachable from the sidebar — silently, since the header still looks
// clickable. This asserts the property the conversion depended on.
html := renderNavFor(t, "dashboard")
for _, href := range []string{"/storage", "/backups", "/sharing"} {
if !strings.Contains(html, `href="`+href+`"`) {
t.Errorf("no sidebar link to %q — a group landing page is unreachable now that its header is a <button>", href)
}
}
}
func TestNavGroup_HeaderIsARealButtonWithAControlsTarget(t *testing.T) {
html := renderNavFor(t, "storage")
// Keyboard/AT reachability rides on it being a real button, not a div with a click handler.
if strings.Contains(html, `<div class="nav-group-toggle`) {
t.Error("nav-group-toggle rendered as a div — keyboard and assistive-tech reachability would be simulated, not real")
}
for id, g := range parseNavGroups(t, html) {
if !strings.HasPrefix(g.btnClass, "nav-group-toggle") {
t.Errorf("group %s: toggle class %q", id, g.btnClass)
}
// aria-controls must point at a list that actually exists in the document.
if !strings.Contains(html, `id="`+g.id+`"`) {
t.Errorf("group %s: aria-controls=%q targets no element", id, g.id)
}
}
}