Files
felhom-controller/controller/internal/web/dashboard_layout_test.go
T
admin d5f2e47ea1 Dashboard layout: Apps card header (R-909), launcher header wraps on a phone (R-907), overflow counted after the page filter (R-906)
- Apps card: title row (logo top-aligned, name, address cut with an ellipsis and full in title) over a wrapping
  .stack-tags row; the tags no longer share a space-between row with the name.
- Phone (768px block): .page-header wraps; the share button drops to its own line, text whole.
- GetBannerAlerts(page, lang): the layout's filter before the cap; baseData and /monitoring use it.
- Tests: TestStackCardHeaderLayout, TestR907_PageHeaderWrapsOnAPhone, TestR906_* (all red against 05e12921).
- No Hungarian string changed. The three stacks parity fixtures are re-captured in the next commit
  (this commit alone fails TestI18nParity on them; both are pushed together).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:32:06 +02:00

138 lines
5.5 KiB
Go

package web
import (
"regexp"
"strings"
"testing"
)
// Dashboard layout fixes, 2026-10-08 (REPORT-dashboard-layout.md). A Go test cannot measure pixels; these pin the
// markup and the rules the layout stands on, and the screenshots (felhom.eu/documentation/audits/
// dashboard-layout-2026-10-08/) show the consequence.
// cssRule returns the body of the top-level rule whose selector line is exactly `selector {`.
func cssRule(t *testing.T, css, selector string) string {
t.Helper()
i := strings.Index(css, "\n"+selector+" {")
if i < 0 {
t.Fatalf("style.css has no top-level %q rule", selector)
}
body := css[i+len(selector)+3:]
return body[:strings.IndexByte(body, '}')]
}
// The Apps card header. Before: the title row and every tag were siblings in ONE space-between flex row; with two
// or more tags (any installed app) the one-word name ran under them, and the logo was centred on the name block,
// so a card with an address carried it lower than one without.
//
// RED-PROOF (REPORT): against 05e12921's stacks.html and style.css this fails — no .stack-tags, a tag before it,
// no address title, and the header rule still `justify-content: space-between`.
func TestStackCardHeaderLayout(t *testing.T) {
s := i18nTestServer(t)
var full i18nCase
for _, c := range i18nCases() {
if c.name == "stacks_full" {
full = c
}
}
if full.name == "" {
t.Fatal("no stacks_full parity case")
}
addrRe := regexp.MustCompile(`<a class="subdomain-link" href="https://([^"]+)" target="_blank" title="([^"]*)">\s*<span class="subdomain-text">([^<]*)</span> ↗\s*</a>`)
for _, lang := range []string{"hu", "en"} {
page := renderI18nCase(t, s, lang, full)
cards := strings.Split(page, `<div class="stack-detail-header">`)[1:]
if len(cards) != 8 {
t.Fatalf("%s: %d card headers, want 8", lang, len(cards))
}
addresses := 0
for i, c := range cards {
head := c[:strings.Index(c, `<div class="metarows">`)]
title := strings.Index(head, `<div class="stack-title-row">`)
tags := strings.Index(head, `<div class="stack-tags">`)
if title < 0 || tags < 0 || tags < title {
t.Errorf("%s card %d: want the title row, then a .stack-tags row (title %d, tags %d)", lang, i, title, tags)
continue
}
if first := strings.Index(head, `class="tag `); first < tags {
t.Errorf("%s card %d: a tag outside the tags row (at %d, row at %d)", lang, i, first, tags)
}
if !strings.Contains(head[title:tags], `<div class="stack-title-text">`) {
t.Errorf("%s card %d: the name block has no .stack-title-text (it carries min-width: 0)", lang, i)
}
if strings.Contains(head, "subdomain-link") {
m := addrRe.FindStringSubmatch(head)
if m == nil || m[1] != m[2] || m[2] != m[3] {
t.Errorf("%s card %d: the address must be one ellipsis span with the full address in title: %q", lang, i, m)
}
addresses++
}
}
if addresses != 2 {
t.Errorf("%s: %d addresses, want 2 (filebrowser, privatebin)", lang, addresses)
}
}
data, err := StyleCSS()
if err != nil {
t.Fatal(err)
}
css := string(data)
if h := cssRule(t, css, ".stack-detail-header"); strings.Contains(h, "space-between") || !strings.Contains(h, "flex-direction: column") {
t.Errorf(".stack-detail-header must stack the title row over the tags, not spread them on one row: %s", h)
}
if r := cssRule(t, css, ".stack-title-row"); !strings.Contains(r, "align-items: flex-start") {
t.Errorf(".stack-title-row must align the logo to the top of the name: %s", r)
}
if r := cssRule(t, css, ".stack-title-text"); !strings.Contains(r, "min-width: 0") {
t.Errorf(".stack-title-text needs min-width: 0, or a long address cannot shrink: %s", r)
}
if r := cssRule(t, css, ".stack-tags"); !strings.Contains(r, "flex-wrap: wrap") {
t.Errorf(".stack-tags must wrap (English tags are longer): %s", r)
}
if r := cssRule(t, css, ".subdomain-text"); !strings.Contains(r, "text-overflow: ellipsis") || !strings.Contains(r, "overflow: hidden") {
t.Errorf(".subdomain-text must cut a long address with an ellipsis: %s", r)
}
if r := cssRule(t, css, ".subdomain-link"); !strings.Contains(r, "white-space: nowrap") {
t.Errorf(".subdomain-link must keep the arrow on the address line: %s", r)
}
}
// R-907 — on a phone the launcher's title, domain chip and share button sat on one row that does not wrap; the
// button ran off the right edge (390 px, demo-hp 9201, 2026-10-08).
//
// RED-PROOF (REPORT): against 05e12921's style.css this fails — the 768px block has no .page-header rule.
func TestR907_PageHeaderWrapsOnAPhone(t *testing.T) {
data, err := StyleCSS()
if err != nil {
t.Fatal(err)
}
block := mediaBlock768(t, string(data))
if !regexp.MustCompile(`\.page-header \{[^}]*flex-wrap: wrap`).MatchString(block) {
t.Error("the phone block must let .page-header wrap")
}
if !regexp.MustCompile(`\.share-open-btn \{[^}]*white-space: nowrap`).MatchString(block) {
t.Error("the share button's text must stay whole when it drops to its own line")
}
// The button is still in the header it wraps inside, in both languages.
s := i18nTestServer(t)
for _, c := range i18nCases() {
if c.name != "launcher_full" {
continue
}
for _, lang := range []string{"hu", "en"} {
page := renderI18nCase(t, s, lang, c)
i := strings.Index(page, `<div class="page-header">`)
if i < 0 {
t.Fatalf("%s: launcher has no .page-header", lang)
}
head := page[i : i+strings.Index(page[i:], "</div>")]
if !strings.Contains(head, "share-open-btn") {
t.Errorf("%s: the share button left the launcher header", lang)
}
}
return
}
t.Fatal("no launcher_full parity case")
}