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, ``) { t.Error(" 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 {{.CustomerName}} 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 `` 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 ` 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=") } }