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
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user