Files
felhom-controller/controller/internal/web/dashboard_layout_test.go
T
admin 423b6d3404
gates / gates (push) Successful in 1m5s
Dashboard banners on a phone wrap inside the banner; the banner link takes the banner's colour
Seen on scratch 9202 with the R-906 build: once the real banner warnings showed (instead of an empty
overflow line), the link ran 90 px past a 390 px screen and rendered browser-default blue. Phone block:
.alert-banner wraps, .alert-message breaks long paths, .alert-link on its own line; .alert-link color: inherit.
Pinned in TestR907_PageHeaderWrapsOnAPhone (red against d5f2e47's CSS). No Hungarian string changed.

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

149 lines
6.2 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, no
// banner rule, and .alert-link has no colour.
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")
}
// A banner on a phone: the long message breaks and the link wraps inside the banner (seen on 9202 once R-906
// let the real banners through: the link ran past the screen). And the link has a colour of its own.
for _, re := range []string{`\.alert-banner \{[^}]*flex-wrap: wrap`, `\.alert-message \{[^}]*min-width: 0[^}]*overflow-wrap: anywhere`, `\.alert-link \{[^}]*flex-basis: 100%`} {
if !regexp.MustCompile(re).MatchString(block) {
t.Errorf("the phone block lacks %s", re)
}
}
if r := cssRule(t, string(data), ".alert-link"); !strings.Contains(r, "color: inherit") {
t.Errorf(".alert-link must take the banner's colour, not the browser's link blue: %s", r)
}
// 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")
}