Files
felhom-controller/controller/internal/web/mobile_nav_test.go
T
admin 2487681396 style: gofmt normalization — no logic changes
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
2026-07-25 07:37:02 +02:00

216 lines
8.4 KiB
Go

package web
import (
"bytes"
"strings"
"testing"
)
// v0.166.0 mobile navigation rework. The old @media(max-width:768px) block predated the v0.146.0
// accordion: it flattened .nav-links into a horizontal, overflow-x strip, which clipped the
// accordion's nested sub-lists (they share the .nav-links class). This suite pins the replacement:
// a hamburger + off-canvas drawer that REUSES the existing vertical sidebar, plus a no-JS static
// fallback so navigation never dead-ends. Desktop (>768px) is untouched — the accordion invariants
// stay in nav_accordion_test.go and must pass unchanged.
// mediaBlock768 returns the full brace-balanced body of the @media(max-width: 768px) rule so CSS
// pins are scoped to the mobile block rather than matching an unrelated rule elsewhere in the sheet.
func mediaBlock768(t *testing.T, css string) string {
t.Helper()
marker := "@media(max-width: 768px)"
i := strings.Index(css, marker)
if i < 0 {
t.Fatalf("no %q media block found — the mobile breakpoint moved or was renamed", marker)
}
open := strings.IndexByte(css[i:], '{')
if open < 0 {
t.Fatal("no opening brace after the 768px media marker")
}
start := i + open
depth := 0
for j := start; j < len(css); j++ {
switch css[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return css[start : j+1]
}
}
}
t.Fatal("unbalanced braces in the 768px media block")
return ""
}
// Group B — CSS pins (Scenario A/B/C).
//
// Red-proof: run against the pre-edit style.css and the strip-absence assertions FAIL — the old
// block still carries `.nav-links { display:flex; ... overflow-x:auto }`.
func TestMobileCSS_StripRemovedDrawerPresent(t *testing.T) {
data, err := StyleCSS()
if err != nil {
t.Fatalf("StyleCSS(): %v", err)
}
css := string(data)
block := mediaBlock768(t, css)
// The horizontal strip that clipped the accordion is gone from the mobile block.
if strings.Contains(block, "overflow-x") {
t.Error("768px block still contains an overflow-x rule — the horizontal .nav-links strip was not removed")
}
if strings.Contains(block, ".nav-links {") || strings.Contains(block, ".nav-links{") {
t.Error("768px block still flattens .nav-links — the strip pattern must be deleted, not patched")
}
// The off-canvas drawer + no-JS fallback are present in the mobile block.
for _, want := range []string{
".js .sidebar", // JS path: sidebar becomes an off-canvas drawer
"translateX(-100%)", // parked off-canvas until opened
".no-js .sidebar", // no-JS path: sidebar renders static inline
"body.nav-open", // scroll lock while the drawer is open
".mobile-topbar", // the sticky top bar is styled inside the mobile block
} {
if !strings.Contains(block, want) {
t.Errorf("768px block missing %q — the drawer/fallback rules are incomplete", want)
}
}
// The desktop defaults that keep >768px pixel-identical live OUTSIDE the media query.
for _, want := range []string{".mobile-topbar", ".nav-backdrop", ".nav-burger"} {
if !strings.Contains(css, want) {
t.Errorf("stylesheet missing base rule for %q", want)
}
}
if !strings.Contains(css, ".mobile-topbar { display: none") &&
!strings.Contains(css, ".mobile-topbar{display:none") {
t.Error("no desktop-default `.mobile-topbar { display:none }` — the top bar would leak onto desktop")
}
}
// The sidebar logo is horizontally centered in its header (desktop sidebar + mobile drawer share
// the element). Red-proof: revert to `margin-bottom: 0.25rem` (no auto side-margins) → this FAILS.
func TestSidebarLogo_Centered(t *testing.T) {
data, err := StyleCSS()
if err != nil {
t.Fatalf("StyleCSS(): %v", err)
}
css := string(data)
i := strings.Index(css, ".sidebar-logo {")
if i < 0 {
t.Fatal(".sidebar-logo rule not found")
}
rule := css[i : i+strings.Index(css[i:], "}")]
if !strings.Contains(rule, "margin: 0 auto") {
t.Errorf(".sidebar-logo is not centered (no `margin: 0 auto`); rule was:\n%s", rule)
}
}
// Group A — drawer markup (Scenario A/C), asserted through the real layout render.
//
// Red-proof: remove aria-controls from the burger button in layout.html → the aria-controls
// assertion FAILS. (Recorded in REPORT.)
func TestMobileNav_TopbarAndDrawerMarkup(t *testing.T) {
html := renderNavFor(t, "dashboard")
// Progressive-enhancement hook: the html element starts as no-js and the head script swaps it.
if !strings.Contains(html, `<html lang="hu" class="no-js">`) {
t.Error("<html> element does not carry class=\"no-js\" — the no-JS CSS fallback would never engage")
}
if !strings.Contains(html, `replace('no-js','js')`) {
t.Error("head script does not swap no-js → js")
}
// Sticky top bar with a single burger toggle wired to the sidebar.
if !strings.Contains(html, `class="mobile-topbar"`) {
t.Error("no .mobile-topbar element rendered")
}
if !strings.Contains(html, `class="nav-burger"`) {
t.Error("no .nav-burger button rendered")
}
if !strings.Contains(html, `aria-expanded="false"`) {
t.Error("burger missing aria-expanded=\"false\" initial state")
}
if !strings.Contains(html, `aria-controls="sidebar"`) {
t.Error("burger missing aria-controls=\"sidebar\" — the button is not associated with the drawer")
}
// The sidebar is the drawer target, and the backdrop starts hidden.
if !strings.Contains(html, `id="sidebar"`) {
t.Error("sidebar has no id=\"sidebar\" — aria-controls points at nothing")
}
if !strings.Contains(html, `class="nav-backdrop"`) {
t.Error("no .nav-backdrop element rendered")
}
// The backdrop must default hidden (both the attribute and its element must be present together).
if !strings.Contains(html, `class="nav-backdrop" hidden`) {
t.Error("nav-backdrop is not initially hidden")
}
}
// Group D — customer name (Scenario D).
//
// Red-proof: re-add <span class="customer-name">{{.CustomerName}}</span> to the sidebar header →
// TestSidebar_NoCustomerName FAILS.
func TestSidebar_NoCustomerName(t *testing.T) {
html := renderNavFor(t, "dashboard")
if strings.Contains(html, "customer-name") {
t.Error("sidebar still renders the customer-name element — it must live only on the login page")
}
}
func TestLogin_CustomerNameKept(t *testing.T) {
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
data := map[string]interface{}{
"Title": "Bejelentkezés",
"CustomerName": "Teszt Ügyfél",
"Version": "9.9.9",
}
if err := s.tmpl.ExecuteTemplate(&buf, "login", data); err != nil {
t.Fatalf("render login: %v", err)
}
out := buf.String()
// The login page still identifies whose box this is.
if !strings.Contains(out, "Teszt Ügyfél") {
t.Error("login page dropped the CustomerName subtitle — it identifies the box owner and must stay")
}
if !strings.Contains(out, `class="login-subtitle"`) {
t.Error("login-subtitle element missing")
}
}
// Group E — logo constants carry NO live text (Scenario E). Under `<img>` secure static mode only
// locally-installed fonts resolve, so a `font-family` in the SVG renders a fallback font everywhere;
// the wordmark must be outlined paths. Shipped in v0.167.0 once the outlined master landed on
// felhom.eu main and the vestigial empty text nodes were stripped.
//
// Red-proof (recorded 2026-07-24): run against the pre-v0.167.0 constants → FAILS, both constants
// contained `<text` + `font-family`.
func TestLogoSVG_NoLiveText(t *testing.T) {
for name, s := range map[string]string{"FelhomLogoSVG": FelhomLogoSVG, "FelhomFaviconSVG": FelhomFaviconSVG} {
if strings.Contains(s, "<text") {
t.Errorf("%s contains a <text element — the wordmark must be outlined paths, not live text", name)
}
if strings.Contains(s, "font-family") {
t.Errorf("%s contains font-family — it renders in a fallback font under <img> secure static mode", name)
}
}
}
// Group E — versioned asset URLs (Scenario E).
//
// Red-proof: run against the pre-edit layout.html → both assertions FAIL (the /static/ URLs lack
// the ?v= cache-bust). Cloudflare edge-caches /static/* for 4h, so an unversioned logo/favicon keeps
// serving the previous release after a deploy (same failure mode as the 0.126.1 CSS incident).
func TestLayout_VersionedAssetURLs(t *testing.T) {
html := renderNavFor(t, "dashboard")
if !strings.Contains(html, "/static/felhom-logo.svg?v=") {
t.Error("sidebar logo URL is not cache-busted with ?v= — a deploy would keep serving the stale edge copy")
}
if !strings.Contains(html, "/static/favicon.svg?v=") {
t.Error("favicon URL is not cache-busted with ?v=")
}
}